@simonyea/holysheep-cli 1.7.134 → 1.7.136

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.
@@ -3,700 +3,1445 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>HolySheep WebUI</title>
6
+ <title>HolySheep Workspace</title>
7
7
  <style>
8
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
8
+ * { box-sizing: border-box; }
9
9
  :root {
10
- --bg: #f5f6fa; --surface: #fff; --surface2: #f0f1f5;
11
- --text: #1a1a2e; --text2: #777; --border: #e2e4ea;
12
- --primary: #e8a46a; --primary-dim: rgba(232,164,106,0.12);
13
- --success: #22c55e; --success-dim: rgba(34,197,94,0.10);
14
- --warning: #f59e0b; --warning-dim: rgba(245,158,11,0.10);
15
- --error: #ef4444; --error-dim: rgba(239,68,68,0.10);
16
- --radius: 12px; --shadow: 0 1px 4px rgba(0,0,0,0.06);
17
- --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
18
- --mono: "SF Mono", "Cascadia Code", Consolas, monospace;
10
+ --bg: #f4f4ef;
11
+ --panel: #fcfbf8;
12
+ --panel-2: #f6f1e9;
13
+ --panel-3: #ece4d8;
14
+ --line: rgba(59, 44, 29, 0.12);
15
+ --text: #231c14;
16
+ --muted: #7a6855;
17
+ --accent: #d06e2b;
18
+ --accent-2: #e89c66;
19
+ --success: #2e8b57;
20
+ --error: #b2412d;
21
+ --warning: #b57f20;
22
+ --shadow: 0 20px 50px rgba(73, 48, 20, 0.08);
23
+ --radius-xl: 22px;
24
+ --radius-lg: 16px;
25
+ --radius-md: 12px;
26
+ --font-ui: "SF Pro Display", "Segoe UI", sans-serif;
27
+ --font-mono: "SF Mono", "Cascadia Code", monospace;
19
28
  }
20
29
  @media (prefers-color-scheme: dark) {
21
30
  :root {
22
- --bg: #0d0d1a; --surface: #181828; --surface2: #1e1e32;
23
- --text: #e4e4f0; --text2: #888; --border: #2a2a44;
24
- --shadow: 0 1px 4px rgba(0,0,0,0.3);
31
+ --bg: #13110f;
32
+ --panel: #1a1714;
33
+ --panel-2: #211d19;
34
+ --panel-3: #2a241e;
35
+ --line: rgba(255, 245, 231, 0.08);
36
+ --text: #f6efe6;
37
+ --muted: #b49d87;
38
+ --accent: #ef8d45;
39
+ --accent-2: #f1b281;
40
+ --success: #5bc48d;
41
+ --error: #eb7c67;
42
+ --warning: #f4bf5a;
43
+ --shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
25
44
  }
26
45
  }
27
- html { font-size: 15px; }
28
- body { font-family: var(--font); background: var(--bg); color: var(--text); min-height: 100vh; }
29
- a { color: var(--primary); text-decoration: none; }
30
- a:hover { text-decoration: underline; }
31
-
32
- /* Layout */
33
- .app { max-width: 720px; margin: 0 auto; padding: 16px 20px 40px; }
34
-
35
- /* Header */
36
- .header { display: flex; align-items: center; justify-content: space-between; padding: 16px 0 12px; }
37
- .header-left { display: flex; align-items: center; gap: 12px; }
38
- .header-left h1 { font-size: 1.3rem; font-weight: 700; }
39
- .header-left h1 span { color: var(--primary); }
40
- .header-left a { font-size: 0.8rem; color: var(--text2); }
41
- .header-right { display: flex; align-items: center; gap: 12px; }
42
- .lang-btn { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; font-size: 0.75rem; color: var(--text2); cursor: pointer; font-family: var(--font); }
43
- .lang-btn:hover { border-color: var(--primary); color: var(--primary); }
44
- .ver { color: var(--text2); font-size: 0.8rem; }
45
-
46
- /* Sections */
47
- .section { margin-bottom: 24px; }
48
- .section-title { font-size: 1rem; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between; }
49
- .section-title .hint { font-size: 0.8rem; color: var(--text2); font-weight: 400; }
50
-
51
- /* Cards */
52
- .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; box-shadow: var(--shadow); }
53
-
54
- /* Account */
55
- .account-card { margin-bottom: 24px; }
56
- .account-logged-in .account-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
57
- .account-top .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
58
- .dot-ok { background: var(--success); }
59
- .dot-warn { background: var(--warning); }
60
- .dot-err { background: var(--error); }
61
- .dot-gray { background: var(--text2); }
62
- .account-top .status-text { font-weight: 600; }
63
- .account-top .key-text { font-family: var(--mono); font-size: 0.85rem; color: var(--text2); }
64
- .account-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0 12px; text-align: center; }
65
- .account-stats .stat-val { font-size: 1.2rem; font-weight: 700; color: var(--primary); }
66
- .account-stats .stat-lbl { font-size: 0.75rem; color: var(--text2); margin-top: 2px; }
67
- .account-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
68
- .login-form { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
69
- .login-form input { flex: 1; font-family: var(--mono); background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; font-size: 0.9rem; outline: none; }
70
- .login-form input:focus { border-color: var(--primary); box-shadow: 0 0 0 2px var(--primary-dim); }
71
- .register-link { font-size: 0.8rem; color: var(--text2); margin-top: 8px; }
72
-
73
- /* Buttons */
74
- .btn { font-family: var(--font); cursor: pointer; border: none; border-radius: 6px; padding: 7px 14px; font-size: 0.85rem; transition: all .15s; display: inline-flex; align-items: center; gap: 6px; }
75
- .btn-primary { background: var(--primary); color: #fff; font-weight: 600; }
76
- .btn-primary:hover { filter: brightness(1.08); }
77
- .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
78
- .btn-outline { background: transparent; color: var(--text); border: 1px solid var(--border); }
79
- .btn-outline:hover { border-color: var(--primary); color: var(--primary); }
80
- .btn-danger { background: transparent; color: var(--error); border: 1px solid var(--error-dim); }
81
- .btn-danger:hover { background: var(--error-dim); }
82
- .btn-sm { padding: 5px 10px; font-size: 0.8rem; }
83
- .btn-link { background: none; color: var(--primary); padding: 0; font-size: 0.85rem; }
84
- .btn-link:hover { text-decoration: underline; }
85
-
86
- /* Tool cards */
87
- .tool-grid { display: flex; flex-direction: column; gap: 10px; }
88
- .tool-card { display: flex; align-items: center; gap: 14px; padding: 16px 18px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); transition: border-color .15s; }
89
- .tool-card:hover { border-color: color-mix(in srgb, var(--primary) 40%, var(--border)); }
90
- .tool-card .tool-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
91
- .tool-card .tool-body { flex: 1; min-width: 0; }
92
- .tool-card .tool-name { font-weight: 600; font-size: 0.95rem; }
93
- .tool-card .tool-meta { font-size: 0.8rem; color: var(--text2); margin-top: 2px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
94
- .tool-card .tool-hint { font-size: 0.78rem; color: var(--text2); margin-top: 4px; }
95
- .tool-card .tool-actions { display: flex; gap: 6px; flex-shrink: 0; }
96
- .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.72rem; font-weight: 600; }
97
- .badge-ok { background: var(--success-dim); color: var(--success); }
98
- .badge-warn { background: var(--warning-dim); color: var(--warning); }
99
- .badge-gray { background: var(--surface2); color: var(--text2); }
100
-
101
- /* Console */
102
- .console-area { margin-top: 12px; display: none; }
103
- .console-area.open { display: block; }
104
- .console-area.busy .console-header span::after {
105
- content: ' '; display: inline-block; width: 6px; height: 6px; border-radius: 50%;
106
- background: var(--primary); margin-left: 8px; animation: pulse 1s infinite;
107
- }
108
- @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.2; } }
109
- .console-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
110
- .console-header span { font-size: 0.85rem; font-weight: 600; }
111
- .console { background: #111; color: #ccc; font-family: var(--mono); font-size: 0.78rem; padding: 12px; border-radius: 8px; max-height: 260px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.6; }
112
- .console .ok { color: #4ade80; }
113
- .console .err { color: #f87171; }
114
- .console .warn { color: #fbbf24; }
115
- .console .info { color: #60a5fa; }
116
-
117
- /* Environment */
118
- .env-table { width: 100%; }
119
- .env-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; }
120
- .env-row:last-child { border-bottom: none; }
121
- .env-key { font-family: var(--mono); font-size: 0.8rem; flex: 1; }
122
- .env-val { font-size: 0.8rem; }
123
- .env-rc { font-size: 0.8rem; color: var(--text2); margin-top: 10px; }
124
-
125
- /* Footer */
126
- .footer { border-top: 1px solid var(--border); padding-top: 20px; margin-top: 32px; text-align: center; }
127
- .footer-brand { font-size: 1.1rem; font-weight: 700; margin-bottom: 8px; }
128
- .footer-brand span { color: var(--primary); }
129
- .footer-links { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; font-size: 0.85rem; margin-bottom: 8px; }
130
- .footer-sub { font-size: 0.78rem; color: var(--text2); }
131
-
132
- /* Update banner */
133
- .update-banner { background: var(--primary-dim); border: 1px solid var(--primary); border-radius: 8px; padding: 10px 16px; margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; font-size: 0.85rem; }
134
-
135
- /* Misc */
136
- .loading { color: var(--text2); font-size: 0.85rem; }
137
- .hidden { display: none !important; }
138
- .mono { font-family: var(--mono); }
46
+ html, body { margin: 0; min-height: 100%; background: radial-gradient(circle at top left, rgba(208,110,43,0.10), transparent 30%), var(--bg); color: var(--text); font-family: var(--font-ui); }
47
+ button, input, textarea, select { font: inherit; }
48
+ button { cursor: pointer; }
49
+ .app-shell {
50
+ display: grid;
51
+ grid-template-columns: 270px 1fr;
52
+ min-height: 100vh;
53
+ }
54
+ .sidebar {
55
+ padding: 28px 18px;
56
+ border-right: 1px solid var(--line);
57
+ background: linear-gradient(180deg, rgba(208,110,43,0.08), transparent 18%), var(--panel);
58
+ position: sticky;
59
+ top: 0;
60
+ height: 100vh;
61
+ overflow: auto;
62
+ }
63
+ .brand {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: space-between;
67
+ margin-bottom: 24px;
68
+ }
69
+ .brand-title {
70
+ font-size: 22px;
71
+ font-weight: 800;
72
+ letter-spacing: -0.04em;
73
+ }
74
+ .brand-title span { color: var(--accent); }
75
+ .brand-sub {
76
+ color: var(--muted);
77
+ font-size: 12px;
78
+ margin-top: 6px;
79
+ }
80
+ .nav-group {
81
+ margin-bottom: 24px;
82
+ }
83
+ .nav-label {
84
+ font-size: 11px;
85
+ color: var(--muted);
86
+ text-transform: uppercase;
87
+ letter-spacing: 0.14em;
88
+ margin-bottom: 10px;
89
+ }
90
+ .nav-item {
91
+ width: 100%;
92
+ text-align: left;
93
+ border: 1px solid transparent;
94
+ background: transparent;
95
+ color: var(--text);
96
+ padding: 12px 14px;
97
+ border-radius: 14px;
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 10px;
101
+ margin-bottom: 6px;
102
+ }
103
+ .nav-item.active {
104
+ background: var(--panel-2);
105
+ border-color: var(--line);
106
+ box-shadow: var(--shadow);
107
+ }
108
+ .nav-dot {
109
+ width: 8px;
110
+ height: 8px;
111
+ border-radius: 999px;
112
+ background: var(--accent);
113
+ flex: none;
114
+ }
115
+ .sidebar-card {
116
+ background: var(--panel-2);
117
+ border: 1px solid var(--line);
118
+ border-radius: var(--radius-lg);
119
+ padding: 16px;
120
+ }
121
+ .sidebar-card h4 {
122
+ margin: 0 0 8px;
123
+ font-size: 14px;
124
+ }
125
+ .sidebar-card p {
126
+ margin: 0;
127
+ color: var(--muted);
128
+ font-size: 12px;
129
+ line-height: 1.6;
130
+ }
131
+ .main {
132
+ padding: 28px 28px 42px;
133
+ min-width: 0;
134
+ }
135
+ .topbar {
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: space-between;
139
+ gap: 16px;
140
+ margin-bottom: 22px;
141
+ }
142
+ .topbar-title h1 {
143
+ margin: 0;
144
+ font-size: 28px;
145
+ letter-spacing: -0.05em;
146
+ }
147
+ .topbar-title p {
148
+ margin: 8px 0 0;
149
+ color: var(--muted);
150
+ font-size: 13px;
151
+ }
152
+ .topbar-actions {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 12px;
156
+ }
157
+ .pill {
158
+ border-radius: 999px;
159
+ padding: 9px 14px;
160
+ background: var(--panel);
161
+ border: 1px solid var(--line);
162
+ color: var(--muted);
163
+ font-size: 12px;
164
+ }
165
+ .btn {
166
+ border-radius: 12px;
167
+ border: 1px solid var(--line);
168
+ background: var(--panel);
169
+ color: var(--text);
170
+ padding: 10px 14px;
171
+ transition: 0.15s ease;
172
+ }
173
+ .btn:hover { border-color: var(--accent); color: var(--accent); }
174
+ .btn.primary {
175
+ background: var(--accent);
176
+ border-color: var(--accent);
177
+ color: #fff;
178
+ font-weight: 700;
179
+ }
180
+ .btn.primary:hover { filter: brightness(1.06); color: #fff; }
181
+ .btn.warn { color: var(--warning); }
182
+ .btn.danger { color: var(--error); }
183
+ .grid {
184
+ display: grid;
185
+ gap: 18px;
186
+ }
187
+ .cards-3 {
188
+ grid-template-columns: repeat(3, minmax(0, 1fr));
189
+ }
190
+ .panel {
191
+ background: var(--panel);
192
+ border: 1px solid var(--line);
193
+ border-radius: var(--radius-xl);
194
+ box-shadow: var(--shadow);
195
+ }
196
+ .panel-header {
197
+ display: flex;
198
+ align-items: center;
199
+ justify-content: space-between;
200
+ gap: 16px;
201
+ padding: 18px 20px 0;
202
+ }
203
+ .panel-header h2, .panel-header h3 {
204
+ margin: 0;
205
+ font-size: 18px;
206
+ letter-spacing: -0.04em;
207
+ }
208
+ .panel-header p {
209
+ margin: 8px 0 0;
210
+ color: var(--muted);
211
+ font-size: 13px;
212
+ }
213
+ .panel-body { padding: 18px 20px 20px; }
214
+ .metric {
215
+ padding: 20px;
216
+ }
217
+ .metric-value {
218
+ font-size: 30px;
219
+ font-weight: 800;
220
+ letter-spacing: -0.05em;
221
+ }
222
+ .metric-label {
223
+ margin-top: 8px;
224
+ color: var(--muted);
225
+ font-size: 13px;
226
+ }
227
+ .workspace-shell {
228
+ display: grid;
229
+ grid-template-columns: 340px 1fr;
230
+ gap: 18px;
231
+ min-height: calc(100vh - 170px);
232
+ }
233
+ .conversation-rail {
234
+ display: grid;
235
+ grid-template-rows: auto auto 1fr;
236
+ gap: 14px;
237
+ }
238
+ .search-box {
239
+ display: flex;
240
+ align-items: center;
241
+ gap: 10px;
242
+ padding: 12px 14px;
243
+ background: var(--panel-2);
244
+ border-radius: 14px;
245
+ border: 1px solid var(--line);
246
+ }
247
+ .search-box input {
248
+ border: 0;
249
+ outline: none;
250
+ width: 100%;
251
+ background: transparent;
252
+ color: var(--text);
253
+ }
254
+ .conversation-list {
255
+ padding: 6px;
256
+ max-height: calc(100vh - 340px);
257
+ overflow: auto;
258
+ }
259
+ .conversation-item {
260
+ width: 100%;
261
+ text-align: left;
262
+ background: transparent;
263
+ border: 1px solid transparent;
264
+ border-radius: 16px;
265
+ padding: 14px;
266
+ margin-bottom: 8px;
267
+ }
268
+ .conversation-item.active {
269
+ background: var(--panel-2);
270
+ border-color: var(--line);
271
+ }
272
+ .conversation-title {
273
+ font-size: 15px;
274
+ font-weight: 700;
275
+ }
276
+ .conversation-meta {
277
+ margin-top: 8px;
278
+ font-size: 12px;
279
+ color: var(--muted);
280
+ line-height: 1.5;
281
+ }
282
+ .search-result-block {
283
+ padding: 12px 14px;
284
+ border-radius: 14px;
285
+ background: var(--panel-2);
286
+ border: 1px solid var(--line);
287
+ }
288
+ .search-result-title {
289
+ font-size: 12px;
290
+ color: var(--muted);
291
+ margin-bottom: 8px;
292
+ }
293
+ .search-result-item {
294
+ display: block;
295
+ width: 100%;
296
+ text-align: left;
297
+ border: 0;
298
+ background: transparent;
299
+ padding: 8px 0;
300
+ color: var(--text);
301
+ border-bottom: 1px dashed var(--line);
302
+ }
303
+ .search-result-item:last-child { border-bottom: 0; }
304
+ .conversation-stage {
305
+ display: grid;
306
+ grid-template-rows: auto 1fr auto;
307
+ }
308
+ .stage-toolbar {
309
+ display: flex;
310
+ align-items: center;
311
+ justify-content: space-between;
312
+ gap: 12px;
313
+ padding: 18px 20px;
314
+ border-bottom: 1px solid var(--line);
315
+ }
316
+ .stage-toolbar-left {
317
+ display: flex;
318
+ align-items: center;
319
+ gap: 10px;
320
+ flex-wrap: wrap;
321
+ }
322
+ .stage-toolbar h3 { margin: 0; font-size: 20px; }
323
+ .badge {
324
+ display: inline-flex;
325
+ align-items: center;
326
+ gap: 6px;
327
+ border-radius: 999px;
328
+ padding: 6px 10px;
329
+ font-size: 12px;
330
+ border: 1px solid var(--line);
331
+ color: var(--muted);
332
+ background: var(--panel-2);
333
+ }
334
+ .badge.ok { color: var(--success); }
335
+ .badge.error { color: var(--error); }
336
+ .badge.warn { color: var(--warning); }
337
+ .message-list {
338
+ padding: 18px 20px;
339
+ overflow: auto;
340
+ min-height: 420px;
341
+ }
342
+ .message {
343
+ max-width: 82%;
344
+ padding: 14px 16px;
345
+ border-radius: 18px;
346
+ margin-bottom: 14px;
347
+ line-height: 1.7;
348
+ white-space: pre-wrap;
349
+ word-break: break-word;
350
+ }
351
+ .message.user {
352
+ margin-left: auto;
353
+ background: linear-gradient(180deg, rgba(208,110,43,0.12), rgba(208,110,43,0.18));
354
+ border: 1px solid rgba(208,110,43,0.18);
355
+ }
356
+ .message.assistant {
357
+ background: var(--panel-2);
358
+ border: 1px solid var(--line);
359
+ }
360
+ .message.error { border-color: rgba(178, 65, 45, 0.35); }
361
+ .message-meta {
362
+ margin-top: 10px;
363
+ font-size: 11px;
364
+ color: var(--muted);
365
+ }
366
+ .composer {
367
+ padding: 18px 20px 20px;
368
+ border-top: 1px solid var(--line);
369
+ }
370
+ .composer textarea,
371
+ .field textarea,
372
+ .field input,
373
+ .field select {
374
+ width: 100%;
375
+ border-radius: 14px;
376
+ border: 1px solid var(--line);
377
+ background: var(--panel-2);
378
+ color: var(--text);
379
+ outline: none;
380
+ padding: 12px 14px;
381
+ }
382
+ .composer textarea { min-height: 112px; resize: vertical; }
383
+ .field {
384
+ margin-bottom: 14px;
385
+ }
386
+ .field label {
387
+ display: block;
388
+ margin-bottom: 8px;
389
+ font-size: 12px;
390
+ text-transform: uppercase;
391
+ letter-spacing: 0.08em;
392
+ color: var(--muted);
393
+ }
394
+ .form-grid {
395
+ display: grid;
396
+ grid-template-columns: repeat(2, minmax(0, 1fr));
397
+ gap: 14px;
398
+ }
399
+ .inline-actions {
400
+ display: flex;
401
+ gap: 10px;
402
+ flex-wrap: wrap;
403
+ }
404
+ .notice {
405
+ border-radius: 16px;
406
+ padding: 14px 16px;
407
+ background: rgba(208, 110, 43, 0.08);
408
+ border: 1px solid rgba(208, 110, 43, 0.18);
409
+ color: var(--muted);
410
+ line-height: 1.6;
411
+ }
412
+ .task-list {
413
+ display: grid;
414
+ gap: 12px;
415
+ }
416
+ .task-card {
417
+ border: 1px solid var(--line);
418
+ background: var(--panel-2);
419
+ border-radius: 16px;
420
+ padding: 16px;
421
+ }
422
+ .task-top {
423
+ display: flex;
424
+ align-items: flex-start;
425
+ justify-content: space-between;
426
+ gap: 12px;
427
+ }
428
+ .task-title {
429
+ font-size: 15px;
430
+ font-weight: 700;
431
+ }
432
+ .task-prompt {
433
+ margin-top: 10px;
434
+ color: var(--muted);
435
+ line-height: 1.6;
436
+ font-size: 13px;
437
+ white-space: pre-wrap;
438
+ }
439
+ .task-meta {
440
+ margin-top: 12px;
441
+ display: flex;
442
+ gap: 10px;
443
+ flex-wrap: wrap;
444
+ font-size: 12px;
445
+ color: var(--muted);
446
+ }
447
+ .tool-grid {
448
+ display: grid;
449
+ gap: 12px;
450
+ }
451
+ .tool-card {
452
+ border: 1px solid var(--line);
453
+ background: var(--panel-2);
454
+ border-radius: 18px;
455
+ padding: 16px;
456
+ }
457
+ .tool-head {
458
+ display: flex;
459
+ align-items: flex-start;
460
+ justify-content: space-between;
461
+ gap: 14px;
462
+ }
463
+ .tool-name {
464
+ font-size: 16px;
465
+ font-weight: 700;
466
+ }
467
+ .tool-hint {
468
+ margin-top: 8px;
469
+ font-size: 13px;
470
+ color: var(--muted);
471
+ line-height: 1.6;
472
+ }
473
+ .tool-actions {
474
+ margin-top: 16px;
475
+ display: flex;
476
+ gap: 10px;
477
+ flex-wrap: wrap;
478
+ }
479
+ .table {
480
+ display: grid;
481
+ gap: 12px;
482
+ }
483
+ .row {
484
+ display: flex;
485
+ align-items: center;
486
+ justify-content: space-between;
487
+ gap: 12px;
488
+ padding: 12px 0;
489
+ border-bottom: 1px solid var(--line);
490
+ }
491
+ .row:last-child { border-bottom: 0; }
492
+ .row-key {
493
+ color: var(--muted);
494
+ font-size: 13px;
495
+ }
496
+ .row-value {
497
+ font-family: var(--font-mono);
498
+ font-size: 13px;
499
+ }
500
+ .empty {
501
+ padding: 32px 12px;
502
+ text-align: center;
503
+ color: var(--muted);
504
+ }
505
+ .console-drawer {
506
+ position: fixed;
507
+ right: 22px;
508
+ bottom: 22px;
509
+ width: min(760px, calc(100vw - 44px));
510
+ border-radius: 18px;
511
+ border: 1px solid var(--line);
512
+ background: #14110f;
513
+ color: #e9e2d9;
514
+ box-shadow: 0 30px 70px rgba(0, 0, 0, 0.35);
515
+ overflow: hidden;
516
+ display: none;
517
+ }
518
+ .console-drawer.open { display: block; }
519
+ .console-bar {
520
+ display: flex;
521
+ align-items: center;
522
+ justify-content: space-between;
523
+ padding: 12px 16px;
524
+ border-bottom: 1px solid rgba(255,255,255,0.08);
525
+ }
526
+ .console-log {
527
+ padding: 14px 16px;
528
+ font-family: var(--font-mono);
529
+ font-size: 12px;
530
+ line-height: 1.6;
531
+ max-height: 340px;
532
+ overflow: auto;
533
+ white-space: pre-wrap;
534
+ }
535
+ .console-log .ok { color: #67d397; }
536
+ .console-log .error { color: #ff8f7a; }
537
+ .console-log .warn { color: #f3c269; }
538
+ .footer-note {
539
+ margin-top: 24px;
540
+ text-align: center;
541
+ color: var(--muted);
542
+ font-size: 12px;
543
+ }
544
+ @media (max-width: 1080px) {
545
+ .app-shell { grid-template-columns: 1fr; }
546
+ .sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
547
+ .workspace-shell { grid-template-columns: 1fr; }
548
+ .cards-3, .form-grid { grid-template-columns: 1fr; }
549
+ }
139
550
  </style>
140
551
  </head>
141
552
  <body>
142
- <div class="app" id="app">
143
- <div class="header">
144
- <div class="header-left">
145
- <h1><span>HolySheep</span></h1>
146
- <a href="https://holysheep.ai" target="_blank">holysheep.ai</a>
553
+ <div class="app-shell">
554
+ <aside class="sidebar">
555
+ <div class="brand">
556
+ <div>
557
+ <div class="brand-title"><span>HolySheep</span> Workspace</div>
558
+ <div class="brand-sub">AionUi-style Web cockpit for HolySheep CLI</div>
559
+ </div>
147
560
  </div>
148
- <div class="header-right">
149
- <button class="lang-btn" onclick="toggleLang()" id="lang-btn"></button>
150
- <span class="ver" id="version"></span>
561
+ <div class="nav-group">
562
+ <div class="nav-label">Workspace</div>
563
+ <button class="nav-item" data-route="dashboard"><span class="nav-dot"></span><span>Dashboard</span></button>
564
+ <button class="nav-item" data-route="workspace"><span class="nav-dot"></span><span>Conversations</span></button>
565
+ <button class="nav-item" data-route="tasks"><span class="nav-dot"></span><span>Scheduled Tasks</span></button>
566
+ <button class="nav-item" data-route="tools"><span class="nav-dot"></span><span>Tools & MCP</span></button>
567
+ <button class="nav-item" data-route="account"><span class="nav-dot"></span><span>HolySheep Account</span></button>
151
568
  </div>
152
- </div>
569
+ <div class="sidebar-card">
570
+ <h4>Why API config lives under tasks</h4>
571
+ <p>The HolySheep API section under Scheduled Tasks is the default runtime config for workspace conversations and task execution. Without it, the workspace cannot run model-backed flows.</p>
572
+ </div>
573
+ </aside>
153
574
 
154
- <div id="account-section" class="section"></div>
155
- <div id="tools-section" class="section"></div>
156
- <div id="console-section" class="console-area" id="console-area">
157
- <div class="console-header">
158
- <span id="console-title"></span>
159
- <button class="btn btn-sm btn-outline" onclick="closeConsole()" id="console-close-btn"></button>
575
+ <main class="main">
576
+ <div class="topbar">
577
+ <div class="topbar-title">
578
+ <h1 id="page-title">Dashboard</h1>
579
+ <p id="page-subtitle">Modern local workspace for HolySheep CLI.</p>
580
+ </div>
581
+ <div class="topbar-actions">
582
+ <div class="pill" id="status-pill">Loading...</div>
583
+ <button class="btn" id="refresh-btn">Refresh</button>
584
+ </div>
160
585
  </div>
161
- <div class="console" id="console-output"></div>
586
+
587
+ <div id="page-content"></div>
588
+ <div class="footer-note">`hs web` now hosts the HolySheep workspace shell, task scheduler, and API-backed conversations.</div>
589
+ </main>
590
+ </div>
591
+
592
+ <div class="console-drawer" id="console-drawer">
593
+ <div class="console-bar">
594
+ <strong id="console-title">Activity Log</strong>
595
+ <button class="btn" id="console-close">Close</button>
162
596
  </div>
163
- <div id="env-section" class="section"></div>
164
- <div id="footer-section"></div>
597
+ <div class="console-log" id="console-log"></div>
165
598
  </div>
166
599
 
167
600
  <script>
168
- // ── i18n ─────────────────────────────────────────────────────────────────────
169
- const I18N = {
170
- zh: {
171
- loggedIn: '已登录', notLoggedIn: '未登录', login: '登录', logout: '退出登录',
172
- balance: '余额', today: '今日消费', month: '本月消费', calls: '累计调用',
173
- recharge: '充值', register: '没有账号?去注册',
174
- apiKeyPlaceholder: '请输入 API Key (cr_xxx)',
175
- tools: 'AI 工具', toolsHint: '一键配置使用 HolySheep API', upgrade: '升级工具',
176
- installed: '已安装', notInstalled: '未安装', configured: '已配置', notConfigured: '未配置',
177
- configure: '一键配置', reconfigure: '重新配置', reset: '重置', install: '安装',
178
- installManual: '手动安装',
179
- env: '环境变量', cleanConflicts: '清理冲突变量', cleaning: '清理中...',
180
- set: '已设置', notSet: '未设置',
181
- shellConfig: 'Shell 配置', managedBlock: '有托管块', noManagedBlock: '无托管块',
182
- docs: '使用文档', pricing: '价格', support: '联系支持',
183
- slogan: '官方 Claude / GPT / Gemini API 代理 · ¥1 = $1',
184
- checking: '加载中...', close: '关闭', log: '操作日志',
185
- confirmReset: '确认重置此工具的 HolySheep 配置?',
186
- configSuccess: '配置成功', configFailed: '配置失败',
187
- installSuccess: '安装完成', installFailed: '安装失败',
188
- needLogin: '请先登录', cleanDone: '已清理',
189
- hotReload: '已生效,无需重启', needRestart: '重启终端后生效',
190
- launch: '启动命令', upgradeOne: '升级', rollback: '回退版本', open: '打开',
191
- updateAvailable: '有新版本可用', updateNow: '立即升级',
601
+ const state = {
602
+ route: 'dashboard',
603
+ status: null,
604
+ balance: null,
605
+ models: [],
606
+ tools: [],
607
+ workspace: null,
608
+ currentConversationId: null,
609
+ currentConversation: null,
610
+ searchQuery: '',
611
+ searchResults: null,
612
+ }
613
+
614
+ const pageMeta = {
615
+ dashboard: {
616
+ title: 'Dashboard',
617
+ subtitle: 'AionUi-inspired operational overview for HolySheep tooling, conversations, and scheduled runs.',
618
+ },
619
+ workspace: {
620
+ title: 'Conversations',
621
+ subtitle: 'Search from the upper-left, jump into sessions, and chat through the configured HolySheep API runtime.',
192
622
  },
193
- en: {
194
- loggedIn: 'Logged in', notLoggedIn: 'Not logged in', login: 'Login', logout: 'Logout',
195
- balance: 'Balance', today: 'Today', month: 'This Month', calls: 'Total Calls',
196
- recharge: 'Recharge', register: 'No account? Register',
197
- apiKeyPlaceholder: 'Enter API Key (cr_xxx)',
198
- tools: 'AI Tools', toolsHint: 'One-click setup for HolySheep API', upgrade: 'Upgrade Tools',
199
- installed: 'Installed', notInstalled: 'Not installed', configured: 'Configured', notConfigured: 'Not configured',
200
- configure: 'Configure', reconfigure: 'Reconfigure', reset: 'Reset', install: 'Install',
201
- installManual: 'Manual install',
202
- env: 'Environment Variables', cleanConflicts: 'Clean Conflicts', cleaning: 'Cleaning...',
203
- set: 'Set', notSet: 'Not set',
204
- shellConfig: 'Shell Config', managedBlock: 'managed block', noManagedBlock: 'no managed block',
205
- docs: 'Docs', pricing: 'Pricing', support: 'Support',
206
- slogan: 'Official Claude / GPT / Gemini API Proxy',
207
- checking: 'Loading...', close: 'Close', log: 'Activity Log',
208
- confirmReset: 'Reset HolySheep config for this tool?',
209
- configSuccess: 'Configured', configFailed: 'Config failed',
210
- installSuccess: 'Installed', installFailed: 'Install failed',
211
- needLogin: 'Please login first', cleanDone: 'Cleaned',
212
- hotReload: 'Active, no restart needed', needRestart: 'Restart terminal to apply',
213
- launch: 'Launch', upgradeOne: 'Upgrade', rollback: 'Rollback', open: 'Open',
214
- updateAvailable: 'Update available', updateNow: 'Update now',
623
+ tasks: {
624
+ title: 'Scheduled Tasks',
625
+ subtitle: 'Manage recurring prompts and maintain the default HolySheep API config directly below the task list.',
215
626
  },
627
+ tools: {
628
+ title: 'Tools & MCP',
629
+ subtitle: 'Inspect local CLI adapters, launch tools, and run one-click configuration from the same shell.',
630
+ },
631
+ account: {
632
+ title: 'HolySheep Account',
633
+ subtitle: 'Keep authentication, balance, environment, and doctor output visible in one place.',
634
+ },
635
+ }
636
+
637
+ const toolNames = {
638
+ 'claude-code': 'Claude Code',
639
+ codex: 'Codex CLI',
640
+ droid: 'Droid',
641
+ opencode: 'OpenCode',
642
+ openclaw: 'OpenClaw',
643
+ 'env-config': 'Env Config',
644
+ }
645
+
646
+ function esc(value) {
647
+ return String(value == null ? '' : value)
648
+ .replace(/&/g, '&amp;')
649
+ .replace(/</g, '&lt;')
650
+ .replace(/>/g, '&gt;')
651
+ .replace(/"/g, '&quot;')
652
+ }
653
+
654
+ function formatDate(value) {
655
+ if (!value) return 'Never'
656
+ const date = new Date(value)
657
+ if (Number.isNaN(date.getTime())) return 'Never'
658
+ return date.toLocaleString()
659
+ }
660
+
661
+ function api(path, options) {
662
+ return fetch(path.startsWith('/api/') ? path : `/api/${path}`, options).then(async (response) => {
663
+ const payload = await response.json().catch(() => ({}))
664
+ if (!response.ok) {
665
+ const error = new Error(payload.error || payload.message || `HTTP ${response.status}`)
666
+ error.payload = payload
667
+ throw error
668
+ }
669
+ return payload
670
+ })
216
671
  }
217
672
 
218
- let lang = localStorage.getItem('hs-lang') || (navigator.language.startsWith('zh') ? 'zh' : 'en')
219
- const t = (k) => I18N[lang]?.[k] || I18N['en'][k] || k
673
+ async function loadBaseState() {
674
+ const [status, balance, tools, workspace, models] = await Promise.all([
675
+ api('status').catch(() => null),
676
+ api('balance').catch(() => null),
677
+ api('tools').catch(() => []),
678
+ api('workspace/state').catch(() => ({ conversations: [], scheduledTasks: [], holySheepApi: { ready: false }, tools: [] })),
679
+ api('models').catch(() => []),
680
+ ])
681
+ state.status = status
682
+ state.balance = balance
683
+ state.tools = tools
684
+ state.workspace = workspace
685
+ state.models = models
686
+
687
+ if (!state.currentConversationId && workspace.conversations[0]) {
688
+ state.currentConversationId = workspace.conversations[0].id
689
+ }
690
+ if (state.currentConversationId) {
691
+ await loadConversation(state.currentConversationId)
692
+ }
693
+ }
220
694
 
221
- function toggleLang() {
222
- lang = lang === 'zh' ? 'en' : 'zh'
223
- localStorage.setItem('hs-lang', lang)
224
- init()
695
+ async function loadConversation(id) {
696
+ if (!id) {
697
+ state.currentConversation = null
698
+ return
699
+ }
700
+ const conversation = await api(`workspace/conversations/${id}`).catch(() => null)
701
+ state.currentConversationId = conversation ? id : null
702
+ state.currentConversation = conversation
225
703
  }
226
704
 
227
- // ── State ────────────────────────────────────────────────────────────────────
228
- let busy = false
705
+ function updateTopbar() {
706
+ const meta = pageMeta[state.route] || pageMeta.dashboard
707
+ document.getElementById('page-title').textContent = meta.title
708
+ document.getElementById('page-subtitle').textContent = meta.subtitle
709
+ document.getElementById('status-pill').textContent = state.workspace?.holySheepApi?.ready
710
+ ? `HolySheep API ready · ${state.workspace.holySheepApi.model}`
711
+ : 'HolySheep API not configured'
712
+ }
229
713
 
230
- // ── Init ─────────────────────────────────────────────────────────────────────
231
- document.addEventListener('DOMContentLoaded', init)
714
+ function routeFromHash() {
715
+ const hash = window.location.hash.replace(/^#\/?/, '')
716
+ return hash || 'dashboard'
717
+ }
232
718
 
233
- async function init() {
234
- document.getElementById('lang-btn').textContent = lang === 'zh' ? 'EN' : '中文'
235
- document.getElementById('console-close-btn').textContent = t('close')
236
- loadAccount()
237
- loadTools()
238
- loadEnv()
239
- renderFooter()
240
- startUpdateChecker()
719
+ function setRoute(route) {
720
+ state.route = pageMeta[route] ? route : 'dashboard'
721
+ window.location.hash = `#/${state.route}`
722
+ render()
241
723
  }
242
724
 
243
- // ── 定时检查新版本(每 5 分钟)──────────────────────────────────────────────
244
- let _updateTimer = null
245
- function startUpdateChecker() {
246
- if (_updateTimer) return
247
- _updateTimer = setInterval(async () => {
248
- try {
249
- const s = await api('status')
250
- if (s.updateAvailable) {
251
- document.getElementById('version').innerHTML = `v${s.version} <span style="color:var(--primary);cursor:pointer;font-weight:600" onclick="doUpgradeTool('holysheep','HolySheep CLI')" title="${t('updateNow')}"> → v${s.updateAvailable} ${t('updateNow')}</span>`
252
- }
253
- } catch {}
254
- }, 5 * 60 * 1000)
725
+ function bindNav() {
726
+ document.querySelectorAll('.nav-item').forEach((item) => {
727
+ item.classList.toggle('active', item.dataset.route === state.route)
728
+ item.onclick = () => setRoute(item.dataset.route)
729
+ })
255
730
  }
256
731
 
257
- // ── API helper ───────────────────────────────────────────────────────────────
258
- async function api(path, opts) {
259
- const res = await fetch('/api/' + path, opts)
260
- return res.json()
732
+ function renderDashboard() {
733
+ const status = state.status || {}
734
+ const balance = state.balance || {}
735
+ const workspace = state.workspace || { conversations: [], scheduledTasks: [], holySheepApi: { ready: false } }
736
+
737
+ return `
738
+ <div class="grid cards-3">
739
+ ${renderMetricCard('Conversations', workspace.conversations.length, 'Saved workspace sessions')}
740
+ ${renderMetricCard('Scheduled Tasks', workspace.scheduledTasks.length, 'Recurring or one-shot prompts')}
741
+ ${renderMetricCard('API Runtime', workspace.holySheepApi.ready ? 'Ready' : 'Missing', workspace.holySheepApi.ready ? workspace.holySheepApi.model : 'Configure on the tasks page')}
742
+ </div>
743
+ <div class="grid" style="grid-template-columns: 1.2fr 0.8fr; margin-top: 18px;">
744
+ <section class="panel">
745
+ <div class="panel-header">
746
+ <div>
747
+ <h2>Workspace Snapshot</h2>
748
+ <p>Account state, balance, and the most recent conversations.</p>
749
+ </div>
750
+ </div>
751
+ <div class="panel-body">
752
+ <div class="table">
753
+ <div class="row"><div class="row-key">Logged in</div><div class="row-value">${status.loggedIn ? 'Yes' : 'No'}</div></div>
754
+ <div class="row"><div class="row-key">Saved API key</div><div class="row-value">${esc(status.apiKey || 'Not set')}</div></div>
755
+ <div class="row"><div class="row-key">Balance</div><div class="row-value">${balance.balance != null ? `$${Number(balance.balance).toFixed(2)}` : 'Unavailable'}</div></div>
756
+ <div class="row"><div class="row-key">Today cost</div><div class="row-value">${balance.todayCost != null ? `$${Number(balance.todayCost).toFixed(2)}` : 'Unavailable'}</div></div>
757
+ <div class="row"><div class="row-key">Month cost</div><div class="row-value">${balance.monthCost != null ? `$${Number(balance.monthCost).toFixed(2)}` : 'Unavailable'}</div></div>
758
+ </div>
759
+ <div style="margin-top:18px;">
760
+ <h3 style="margin:0 0 10px;">Recent conversations</h3>
761
+ ${workspace.conversations.length ? workspace.conversations.slice(0, 4).map((conversation) => `
762
+ <div class="task-card" style="margin-bottom:10px;">
763
+ <div class="task-top">
764
+ <div>
765
+ <div class="task-title">${esc(conversation.title)}</div>
766
+ <div class="task-meta">
767
+ <span>${esc(toolNames[conversation.toolId] || conversation.toolId)}</span>
768
+ <span>${formatDate(conversation.updatedAt)}</span>
769
+ </div>
770
+ </div>
771
+ <button class="btn" onclick="openConversation('${conversation.id}')">Open</button>
772
+ </div>
773
+ <div class="task-prompt">${esc(conversation.summary || 'No summary yet')}</div>
774
+ </div>
775
+ `).join('') : '<div class="empty">No conversations yet. Create one from the Conversations page.</div>'}
776
+ </div>
777
+ </div>
778
+ </section>
779
+ <section class="panel">
780
+ <div class="panel-header">
781
+ <div>
782
+ <h2>Default Task Runtime</h2>
783
+ <p>The task page owns the default HolySheep API config used by conversations and scheduled jobs.</p>
784
+ </div>
785
+ </div>
786
+ <div class="panel-body">
787
+ <div class="notice">
788
+ <strong>Status:</strong> ${workspace.holySheepApi.ready ? 'Configured' : 'Missing'}<br>
789
+ <strong>Base URL:</strong> ${esc(workspace.holySheepApi.baseUrl || 'Not configured')}<br>
790
+ <strong>Model:</strong> ${esc(workspace.holySheepApi.model || 'Not configured')}
791
+ </div>
792
+ <div style="margin-top:16px;" class="inline-actions">
793
+ <button class="btn primary" onclick="setRoute('tasks')">Open Scheduled Tasks</button>
794
+ <button class="btn" onclick="setRoute('tools')">Manage CLI tools</button>
795
+ </div>
796
+ </div>
797
+ </section>
798
+ </div>
799
+ `
261
800
  }
262
801
 
263
- // ── Account section ──────────────────────────────────────────────────────────
264
- async function loadAccount() {
265
- const el = document.getElementById('account-section')
266
- el.innerHTML = `<div class="card account-card"><span class="loading">${t('checking')}</span></div>`
802
+ function renderMetricCard(label, value, caption) {
803
+ return `
804
+ <section class="panel metric">
805
+ <div class="metric-value">${esc(value)}</div>
806
+ <div class="metric-label">${esc(label)}</div>
807
+ <div class="metric-label" style="margin-top:12px;">${esc(caption)}</div>
808
+ </section>
809
+ `
810
+ }
267
811
 
268
- const [status, balance] = await Promise.allSettled([api('status'), api('balance')])
269
- const s = status.value || {}
270
- const b = balance.value || {}
812
+ function renderConversationRail(workspace) {
813
+ const searchBlock = state.searchQuery.trim()
814
+ ? `
815
+ <div class="search-result-block">
816
+ <div class="search-result-title">Search results</div>
817
+ ${(state.searchResults?.conversations || []).map((item) => `
818
+ <button class="search-result-item" onclick="openConversation('${item.id}')">
819
+ <strong>${esc(item.title)}</strong><br>
820
+ <span style="color:var(--muted);font-size:12px;">${esc(item.summary || '')}</span>
821
+ </button>
822
+ `).join('') || '<div class="empty">No matching conversations.</div>'}
823
+ </div>
824
+ `
825
+ : ''
271
826
 
272
- document.getElementById('version').textContent = 'v' + (s.version || '')
827
+ const list = workspace.conversations.length
828
+ ? workspace.conversations.map((conversation) => `
829
+ <button class="conversation-item ${conversation.id === state.currentConversationId ? 'active' : ''}" onclick="openConversation('${conversation.id}')">
830
+ <div class="conversation-title">${esc(conversation.title)}</div>
831
+ <div class="conversation-meta">
832
+ ${esc(toolNames[conversation.toolId] || conversation.toolId)} · ${formatDate(conversation.updatedAt)}<br>
833
+ ${esc(conversation.summary || 'No summary yet')}
834
+ </div>
835
+ </button>
836
+ `).join('')
837
+ : '<div class="empty">Create your first conversation to begin using the workspace.</div>'
838
+
839
+ return `
840
+ <section class="panel conversation-rail">
841
+ <div class="panel-header">
842
+ <div>
843
+ <h2>Sessions</h2>
844
+ <p>Search in the upper-left, then jump between CLI work sessions.</p>
845
+ </div>
846
+ <button class="btn primary" onclick="createConversation()">New</button>
847
+ </div>
848
+ <div class="panel-body" style="padding-top:0;">
849
+ <div class="search-box">
850
+ <span>Search</span>
851
+ <input id="conversation-search" placeholder="Search titles and message text" value="${esc(state.searchQuery)}">
852
+ </div>
853
+ ${searchBlock}
854
+ </div>
855
+ <div class="conversation-list">${list}</div>
856
+ </section>
857
+ `
858
+ }
273
859
 
274
- // 版本更新提示
275
- if (s.updateAvailable) {
276
- document.getElementById('version').innerHTML = `v${s.version} <span style="color:var(--primary);cursor:pointer" onclick="doUpgradeTool('holysheep','HolySheep CLI')" title="${t('updateNow')}">→ v${s.updateAvailable} ${t('updateNow')}</span>`
860
+ function renderConversationStage(workspace) {
861
+ const conversation = state.currentConversation
862
+ const ready = workspace.holySheepApi.ready
863
+ if (!conversation) {
864
+ return `
865
+ <section class="panel conversation-stage">
866
+ <div class="stage-toolbar">
867
+ <div class="notice">No conversation selected. Create one from the left rail.</div>
868
+ </div>
869
+ </section>
870
+ `
277
871
  }
278
872
 
279
- if (s.loggedIn) {
280
- const hasBalance = !b.error && typeof b.balance === 'number'
281
- el.innerHTML = `<div class="card account-card account-logged-in">
282
- <div class="account-top">
283
- <span class="dot dot-ok"></span>
284
- <span class="status-text">${t('loggedIn')}</span>
285
- <span class="key-text">${esc(s.apiKey)}</span>
873
+ const toolOptions = workspace.tools.map((tool) => `
874
+ <option value="${esc(tool.id)}" ${tool.id === conversation.toolId ? 'selected' : ''}>${esc(tool.name)}</option>
875
+ `).join('')
876
+
877
+ const messages = conversation.messages.length
878
+ ? conversation.messages.map((message) => `
879
+ <div class="message ${message.role} ${message.status === 'error' ? 'error' : ''}">
880
+ ${esc(message.content)}
881
+ <div class="message-meta">${message.role === 'assistant' ? 'Assistant' : 'User'} · ${formatDate(message.createdAt)}${message.meta?.model ? ` · ${esc(message.meta.model)}` : ''}</div>
286
882
  </div>
287
- ${hasBalance ? `
288
- <div class="account-stats">
289
- <div><div class="stat-val">$${fmtNum(b.balance)}</div><div class="stat-lbl">${t('balance')}</div></div>
290
- <div><div class="stat-val">$${fmtNum(b.todayCost)}</div><div class="stat-lbl">${t('today')}</div></div>
291
- <div><div class="stat-val">$${fmtNum(b.monthCost)}</div><div class="stat-lbl">${t('month')}</div></div>
292
- <div><div class="stat-val">${b.totalCalls.toLocaleString()}</div><div class="stat-lbl">${t('calls')}</div></div>
293
- </div>` : `<div style="color:var(--text2);font-size:0.85rem;margin:10px 0">${b.error ? esc(b.error) : t('checking')}</div>`}
294
- <div class="account-actions">
295
- <a class="btn btn-primary btn-sm" href="https://holysheep.ai/app/recharge" target="_blank">${t('recharge')} &rarr;</a>
296
- <button class="btn btn-danger btn-sm" onclick="doLogout()">${t('logout')}</button>
883
+ `).join('')
884
+ : '<div class="empty">This conversation is empty. Send a prompt to create the first turn.</div>'
885
+
886
+ return `
887
+ <section class="panel conversation-stage">
888
+ <div class="stage-toolbar">
889
+ <div class="stage-toolbar-left">
890
+ <h3>${esc(conversation.title)}</h3>
891
+ <span class="badge ${ready ? 'ok' : 'warn'}">${ready ? 'HolySheep API Ready' : 'Configure API on task page'}</span>
892
+ <span class="badge">${esc(toolNames[conversation.toolId] || conversation.toolId)}</span>
893
+ </div>
894
+ <div class="inline-actions">
895
+ <select id="conversation-tool-select">${toolOptions}</select>
896
+ <button class="btn" onclick="launchCurrentTool()">Launch Tool</button>
897
+ </div>
297
898
  </div>
298
- </div>`
299
- } else {
300
- el.innerHTML = `<div class="card account-card">
301
- <div style="display:flex;align-items:center;gap:8px">
302
- <span class="dot dot-gray"></span>
303
- <span>${t('notLoggedIn')}</span>
899
+ <div class="message-list" id="message-list">${messages}</div>
900
+ <div class="composer">
901
+ ${ready ? '' : '<div class="notice" style="margin-bottom:14px;">The workspace runtime uses the HolySheep API config from the Scheduled Tasks page. Configure API key, base URL, and model there before sending messages.</div>'}
902
+ <textarea id="conversation-input" placeholder="Ask for code changes, debugging steps, or tool usage..." ${ready ? '' : 'disabled'}></textarea>
903
+ <div class="inline-actions" style="margin-top:12px;">
904
+ <button class="btn primary" onclick="sendConversationMessage()" ${ready ? '' : 'disabled'}>Send</button>
905
+ <button class="btn" onclick="renameConversation()">Rename</button>
906
+ <button class="btn" onclick="togglePinConversation()">${conversation.pinned ? 'Unpin' : 'Pin'}</button>
907
+ </div>
304
908
  </div>
305
- <div class="login-form">
306
- <input type="password" id="login-key" placeholder="${t('apiKeyPlaceholder')}">
307
- <button class="btn btn-primary" onclick="doLogin()" id="login-btn">${t('login')}</button>
909
+ </section>
910
+ `
911
+ }
912
+
913
+ function renderWorkspace() {
914
+ const workspace = state.workspace || { conversations: [], tools: [], holySheepApi: { ready: false } }
915
+ return `
916
+ <div class="workspace-shell">
917
+ ${renderConversationRail(workspace)}
918
+ ${renderConversationStage(workspace)}
919
+ </div>
920
+ `
921
+ }
922
+
923
+ function renderTasks() {
924
+ const workspace = state.workspace || { scheduledTasks: [], holySheepApi: { ready: false } }
925
+ const taskCards = workspace.scheduledTasks.length
926
+ ? workspace.scheduledTasks.map((task) => `
927
+ <div class="task-card">
928
+ <div class="task-top">
929
+ <div>
930
+ <div class="task-title">${esc(task.title)}</div>
931
+ <div class="task-meta">
932
+ <span>${esc(task.schedule)}</span>
933
+ <span>${task.active ? 'Active' : 'Paused'}</span>
934
+ <span>Last run: ${formatDate(task.lastRunAt)}</span>
935
+ </div>
936
+ </div>
937
+ <div class="inline-actions">
938
+ <button class="btn" onclick="runTask('${task.id}')">Run now</button>
939
+ <button class="btn" onclick="editTask('${task.id}')">Edit</button>
940
+ <button class="btn danger" onclick="deleteTask('${task.id}')">Delete</button>
941
+ </div>
942
+ </div>
943
+ <div class="task-prompt">${esc(task.prompt)}</div>
944
+ <div class="task-meta">
945
+ <span>Status: ${esc(task.lastStatus || 'idle')}</span>
946
+ <span>${esc(task.lastResult ? task.lastResult.slice(0, 160) : 'No result yet')}</span>
947
+ </div>
308
948
  </div>
309
- <div class="register-link"><a href="https://holysheep.ai/register" target="_blank">${t('register')}</a></div>
310
- </div>`
311
- }
949
+ `).join('')
950
+ : '<div class="empty">No scheduled tasks yet. Create one to run recurring HolySheep prompts.</div>'
951
+
952
+ return `
953
+ <div class="grid" style="grid-template-columns: 1.15fr 0.85fr;">
954
+ <section class="panel">
955
+ <div class="panel-header">
956
+ <div>
957
+ <h2>Tasks</h2>
958
+ <p>Create recurring prompts. They inherit the HolySheep API configuration below.</p>
959
+ </div>
960
+ </div>
961
+ <div class="panel-body">
962
+ <div class="field">
963
+ <label>Task title</label>
964
+ <input id="task-title" placeholder="Nightly code review summary">
965
+ </div>
966
+ <div class="field">
967
+ <label>Task prompt</label>
968
+ <textarea id="task-prompt" rows="5" placeholder="Summarize yesterday's code changes and highlight risky diffs."></textarea>
969
+ </div>
970
+ <div class="form-grid">
971
+ <div class="field">
972
+ <label>Schedule</label>
973
+ <input id="task-schedule" placeholder="5m / 1h / 1d">
974
+ </div>
975
+ <div class="field">
976
+ <label>Model override</label>
977
+ <select id="task-model-override">
978
+ <option value="">Use default HolySheep model</option>
979
+ ${state.models.map((model) => `<option value="${esc(model.id)}">${esc(model.label)}</option>`).join('')}
980
+ </select>
981
+ </div>
982
+ </div>
983
+ <div class="inline-actions">
984
+ <button class="btn primary" onclick="saveTask()">Save task</button>
985
+ <button class="btn" onclick="clearTaskForm()">Clear</button>
986
+ </div>
987
+ <div style="margin-top:18px;" class="task-list">${taskCards}</div>
988
+ </div>
989
+ </section>
990
+ <section class="panel">
991
+ <div class="panel-header">
992
+ <div>
993
+ <h2>HolySheep API</h2>
994
+ <p>This section sits under Scheduled Tasks on purpose: tasks and workspace conversations both depend on it.</p>
995
+ </div>
996
+ </div>
997
+ <div class="panel-body">
998
+ <div class="notice" style="margin-bottom:14px;">
999
+ Without this config, the workspace cannot run model-backed conversations or scheduled tasks.
1000
+ </div>
1001
+ <div class="field">
1002
+ <label>HolySheep API Key</label>
1003
+ <input type="password" id="runtime-api-key" placeholder="cr_xxx" value="${esc(workspace.holySheepApi.apiKey || '')}">
1004
+ </div>
1005
+ <div class="field">
1006
+ <label>Base URL</label>
1007
+ <input id="runtime-base-url" placeholder="https://api.holysheep.ai/v1" value="${esc(workspace.holySheepApi.baseUrl || '')}">
1008
+ </div>
1009
+ <div class="field">
1010
+ <label>Default model</label>
1011
+ <select id="runtime-model">
1012
+ <option value="">Select model</option>
1013
+ ${state.models.map((model) => `<option value="${esc(model.id)}" ${workspace.holySheepApi.model === model.id ? 'selected' : ''}>${esc(model.label)}</option>`).join('')}
1014
+ </select>
1015
+ </div>
1016
+ <div class="inline-actions">
1017
+ <button class="btn primary" onclick="saveRuntimeConfig()">Save runtime config</button>
1018
+ <button class="btn" onclick="copyLoginKey()">Use saved login key</button>
1019
+ </div>
1020
+ <div class="task-meta" style="margin-top:14px;">
1021
+ <span>Ready: ${workspace.holySheepApi.ready ? 'Yes' : 'No'}</span>
1022
+ <span>Model: ${esc(workspace.holySheepApi.model || 'Not configured')}</span>
1023
+ </div>
1024
+ </div>
1025
+ </section>
1026
+ </div>
1027
+ `
312
1028
  }
313
1029
 
314
- async function doLogin() {
315
- const input = document.getElementById('login-key')
316
- const key = input.value.trim()
317
- if (!key) return
318
- const btn = document.getElementById('login-btn')
319
- btn.disabled = true
320
- try {
321
- const r = await api('login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: key }) })
322
- if (r.success) { init() } else { alert(r.message) }
323
- } catch (e) { alert(e.message) }
324
- btn.disabled = false
1030
+ function renderTools() {
1031
+ const tools = state.tools || []
1032
+ return `
1033
+ <section class="panel">
1034
+ <div class="panel-header">
1035
+ <div>
1036
+ <h2>Tools & MCP</h2>
1037
+ <p>Use the existing HolySheep automation endpoints from inside the new workspace shell.</p>
1038
+ </div>
1039
+ </div>
1040
+ <div class="panel-body tool-grid">
1041
+ ${tools.map((tool) => `
1042
+ <div class="tool-card">
1043
+ <div class="tool-head">
1044
+ <div>
1045
+ <div class="tool-name">${esc(tool.name)}</div>
1046
+ <div class="task-meta">
1047
+ <span class="badge ${tool.installed ? 'ok' : 'warn'}">${tool.installed ? 'Installed' : 'Missing'}</span>
1048
+ <span class="badge ${tool.configured ? 'ok' : 'warn'}">${tool.configured ? 'Configured' : 'Needs setup'}</span>
1049
+ <span>${esc(tool.version || '')}</span>
1050
+ </div>
1051
+ </div>
1052
+ <div class="inline-actions">
1053
+ <button class="btn" onclick="launchTool('${tool.id}')">Launch</button>
1054
+ <button class="btn primary" onclick="configureTool('${tool.id}')">${tool.configured ? 'Reconfigure' : 'Configure'}</button>
1055
+ </div>
1056
+ </div>
1057
+ <div class="tool-hint">${esc(tool.hint || 'No additional hint')}</div>
1058
+ </div>
1059
+ `).join('')}
1060
+ </div>
1061
+ </section>
1062
+ `
325
1063
  }
326
1064
 
327
- async function doLogout() {
328
- await api('logout', { method: 'POST' })
329
- init()
1065
+ function renderAccount() {
1066
+ const status = state.status || {}
1067
+ const balance = state.balance || {}
1068
+ return `
1069
+ <div class="grid" style="grid-template-columns: 1fr 1fr;">
1070
+ <section class="panel">
1071
+ <div class="panel-header">
1072
+ <div>
1073
+ <h2>Account</h2>
1074
+ <p>HolySheep login state remains available inside the new workspace shell.</p>
1075
+ </div>
1076
+ </div>
1077
+ <div class="panel-body">
1078
+ <div class="field">
1079
+ <label>Saved key</label>
1080
+ <div class="row-value">${esc(status.apiKey || 'Not logged in')}</div>
1081
+ </div>
1082
+ <div class="field">
1083
+ <label>Login with HolySheep API key</label>
1084
+ <input id="login-api-key" type="password" placeholder="cr_xxx">
1085
+ </div>
1086
+ <div class="inline-actions">
1087
+ <button class="btn primary" onclick="login()">Login</button>
1088
+ <button class="btn danger" onclick="logout()">Logout</button>
1089
+ </div>
1090
+ <div class="task-meta" style="margin-top:14px;">
1091
+ <span>Version: ${esc(status.version || 'Unknown')}</span>
1092
+ <span>${status.updateAvailable ? `Update available: ${esc(status.updateAvailable)}` : 'Up to date'}</span>
1093
+ </div>
1094
+ </div>
1095
+ </section>
1096
+ <section class="panel">
1097
+ <div class="panel-header">
1098
+ <div>
1099
+ <h2>Balance</h2>
1100
+ <p>Same data as the legacy hs web panel, preserved inside the AionUi-style shell.</p>
1101
+ </div>
1102
+ </div>
1103
+ <div class="panel-body table">
1104
+ <div class="row"><div class="row-key">Balance</div><div class="row-value">${balance.balance != null ? `$${Number(balance.balance).toFixed(2)}` : 'Unavailable'}</div></div>
1105
+ <div class="row"><div class="row-key">Today cost</div><div class="row-value">${balance.todayCost != null ? `$${Number(balance.todayCost).toFixed(2)}` : 'Unavailable'}</div></div>
1106
+ <div class="row"><div class="row-key">Month cost</div><div class="row-value">${balance.monthCost != null ? `$${Number(balance.monthCost).toFixed(2)}` : 'Unavailable'}</div></div>
1107
+ <div class="row"><div class="row-key">Total calls</div><div class="row-value">${balance.totalCalls != null ? esc(balance.totalCalls) : 'Unavailable'}</div></div>
1108
+ </div>
1109
+ </section>
1110
+ <section class="panel" style="grid-column: 1 / -1;">
1111
+ <div class="panel-header">
1112
+ <div>
1113
+ <h2>Environment Snapshot</h2>
1114
+ <p>High-value information from doctor remains visible without leaving the web workspace.</p>
1115
+ </div>
1116
+ </div>
1117
+ <div class="panel-body" id="doctor-panel">
1118
+ <div class="empty">Click refresh to load doctor state.</div>
1119
+ </div>
1120
+ </section>
1121
+ </div>
1122
+ `
330
1123
  }
331
1124
 
332
- // ── Tools section ────────────────────────────────────────────────────────────
333
- async function loadTools() {
334
- const el = document.getElementById('tools-section')
335
- el.innerHTML = `<div class="section-title">
336
- <span>${t('tools')} <span class="hint" style="margin-left:8px">${t('toolsHint')}</span></span>
337
- <button class="btn btn-outline btn-sm" onclick="doUpgrade()" ${busy ? 'disabled' : ''}>${t('upgrade')}</button>
1125
+ async function renderDoctorPanel() {
1126
+ const panel = document.getElementById('doctor-panel')
1127
+ if (!panel) return
1128
+ panel.innerHTML = '<div class="empty">Loading doctor output...</div>'
1129
+ const doctor = await api('doctor').catch(() => null)
1130
+ if (!doctor) {
1131
+ panel.innerHTML = '<div class="empty">Doctor output unavailable.</div>'
1132
+ return
1133
+ }
1134
+ panel.innerHTML = `
1135
+ <div class="table">
1136
+ <div class="row"><div class="row-key">Node</div><div class="row-value">${esc(doctor.node?.version || 'Unknown')}</div></div>
1137
+ <div class="row"><div class="row-key">API key</div><div class="row-value">${doctor.apiKey?.set ? esc(doctor.apiKey.masked) : 'Not set'}</div></div>
1138
+ <div class="row"><div class="row-key">Connectivity</div><div class="row-value">${doctor.connectivity?.ok ? `OK (${doctor.connectivity.modelCount} models)` : 'Unavailable'}</div></div>
1139
+ ${Array.isArray(doctor.tools) ? doctor.tools.map((tool) => `
1140
+ <div class="row"><div class="row-key">${esc(tool.name)}</div><div class="row-value">${tool.installed ? (tool.configured ? 'Installed + configured' : 'Installed') : 'Missing'}</div></div>
1141
+ `).join('') : ''}
338
1142
  </div>
339
- <div class="tool-grid"><span class="loading">${t('checking')}</span></div>`
340
-
341
- const tools = await api('tools')
342
- const grid = el.querySelector('.tool-grid')
343
- grid.innerHTML = tools.map(tool => renderToolCard(tool)).join('')
344
- }
345
-
346
- function renderToolCard(tool) {
347
- let dotClass, statusBadges, actions, hintLine
348
-
349
- // Claude Proxy 特殊卡片
350
- if (tool.id === 'claude-proxy') {
351
- if (tool.configured) {
352
- dotClass = 'dot-ok'
353
- statusBadges = `<span class="badge badge-ok">运行中</span>`
354
- actions = `<button class="btn btn-danger btn-sm" onclick="doClaudeProxyStop()">停止代理</button>`
355
- hintLine = tool.version ? `<span class="mono" style="font-size:0.78rem;color:var(--text2)">${esc(tool.version)}</span>` : ''
356
- } else {
357
- dotClass = 'dot-warn'
358
- statusBadges = `<span class="badge badge-warn">未启动</span>`
359
- actions = `<button class="btn btn-primary btn-sm" onclick="doClaudeProxyStart()">启动代理</button>`
360
- hintLine = ''
361
- }
362
- const hintText = tool.hint ? `<div class="tool-hint">${esc(tool.hint)}</div>` : ''
363
- return `<div class="tool-card" id="tool-${tool.id}">
364
- <div class="tool-dot ${dotClass}"></div>
365
- <div class="tool-body">
366
- <div class="tool-name">${esc(tool.name)}</div>
367
- <div class="tool-meta">${statusBadges} ${hintLine}</div>
368
- ${hintText}
369
- </div>
370
- <div class="tool-actions">${actions}</div>
371
- </div>`
1143
+ `
1144
+ }
1145
+
1146
+ function render() {
1147
+ bindNav()
1148
+ updateTopbar()
1149
+ const root = document.getElementById('page-content')
1150
+ if (state.route === 'workspace') {
1151
+ root.innerHTML = renderWorkspace()
1152
+ bindWorkspaceControls()
1153
+ return
1154
+ }
1155
+ if (state.route === 'tasks') {
1156
+ root.innerHTML = renderTasks()
1157
+ return
1158
+ }
1159
+ if (state.route === 'tools') {
1160
+ root.innerHTML = renderTools()
1161
+ return
1162
+ }
1163
+ if (state.route === 'account') {
1164
+ root.innerHTML = renderAccount()
1165
+ void renderDoctorPanel()
1166
+ return
372
1167
  }
1168
+ root.innerHTML = renderDashboard()
1169
+ }
373
1170
 
374
- if (!tool.installed) {
375
- dotClass = 'dot-gray'
376
- statusBadges = `<span class="badge badge-gray">${t('notInstalled')}</span>`
377
- if (tool.canAutoInstall) {
378
- actions = `<button class="btn btn-primary btn-sm" onclick="doInstallTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('install')}</button>`
379
- } else {
380
- actions = `<span class="badge badge-gray" style="font-weight:400">${t('installManual')}: ${esc(tool.installCmd)}</span>`
1171
+ function bindWorkspaceControls() {
1172
+ const searchInput = document.getElementById('conversation-search')
1173
+ if (searchInput) {
1174
+ searchInput.oninput = async (event) => {
1175
+ state.searchQuery = event.target.value
1176
+ if (!state.searchQuery.trim()) {
1177
+ state.searchResults = null
1178
+ render()
1179
+ return
1180
+ }
1181
+ state.searchResults = await api(`workspace/search?q=${encodeURIComponent(state.searchQuery)}`).catch(() => ({ conversations: [], tasks: [] }))
1182
+ render()
381
1183
  }
382
- hintLine = ''
383
- } else if (!tool.configured) {
384
- dotClass = 'dot-warn'
385
- statusBadges = `<span class="badge badge-ok">${t('installed')}</span> <span class="badge badge-warn">${t('notConfigured')}</span>`
386
- const upgradeBtn = tool.canUpgrade ? `<button class="btn btn-outline btn-sm" onclick="doUpgradeTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('upgradeOne')}</button>` : ''
387
- const rollbackBtn = tool.canUpgrade && tool.npmPkg ? `<button class="btn btn-outline btn-sm" onclick="doRollbackTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('rollback')}</button>` : ''
388
- actions = `<button class="btn btn-primary btn-sm" onclick="doConfigureTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('configure')}</button>${upgradeBtn}${rollbackBtn}`
389
- hintLine = tool.version ? `<span class="mono" style="font-size:0.78rem;color:var(--text2)">${esc(tool.version)}</span>` : ''
390
- } else {
391
- dotClass = 'dot-ok'
392
- statusBadges = `<span class="badge badge-ok">${t('installed')}</span> <span class="badge badge-ok">${t('configured')}</span>`
393
- const upgradeBtn = tool.canUpgrade ? `<button class="btn btn-outline btn-sm" onclick="doUpgradeTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('upgradeOne')}</button>` : ''
394
- const rollbackBtn = tool.canUpgrade && tool.npmPkg ? `<button class="btn btn-outline btn-sm" onclick="doRollbackTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('rollback')}</button>` : ''
395
- actions = `<button class="btn btn-primary btn-sm" onclick="doLaunchTool('${tool.id}')">${t('open')}</button>
396
- <button class="btn btn-outline btn-sm" onclick="doConfigureTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('reconfigure')}</button>
397
- ${upgradeBtn}${rollbackBtn}
398
- <button class="btn btn-danger btn-sm" onclick="doResetTool('${tool.id}','${esc(tool.name)}')" ${busy ? 'disabled' : ''}>${t('reset')}</button>`
399
- hintLine = tool.version ? `<span class="mono" style="font-size:0.78rem;color:var(--text2)">${esc(tool.version)}</span>` : ''
400
1184
  }
401
1185
 
402
- const launchLine = tool.installed && tool.launchCmd
403
- ? `<div class="tool-hint">${t('launch')}: <code class="mono">${esc(tool.launchCmd)}</code></div>`
404
- : ''
405
- const hintText = tool.installed && tool.hint
406
- ? `<div class="tool-hint">${esc(tool.hint)}</div>`
407
- : ''
408
-
409
- return `<div class="tool-card" id="tool-${tool.id}">
410
- <div class="tool-dot ${dotClass}"></div>
411
- <div class="tool-body">
412
- <div class="tool-name">${esc(tool.name)}</div>
413
- <div class="tool-meta">${statusBadges} ${hintLine}</div>
414
- ${hintText}${launchLine}
415
- </div>
416
- <div class="tool-actions">${actions}</div>
417
- </div>`
418
- }
419
-
420
- // ── Tool actions ─────────────────────────────────────────────────────────────
421
- async function doConfigureTool(id, name) {
422
- const status = await api('status')
423
- if (!status.loggedIn) { alert(t('needLogin')); return }
424
- if (busy) return
425
- busy = true
426
- openConsole(`${t('configure')}: ${name}`)
427
- document.getElementById('console-section').classList.add('busy')
428
-
429
- await streamSSE('/api/tool/configure', { toolId: id }, (ev) => {
430
- if (ev.type === 'progress') appendLog(ev.message, 'info')
431
- else if (ev.type === 'result') {
432
- const cls = ev.status === 'ok' ? 'ok' : ev.status === 'warning' ? 'warn' : 'err'
433
- appendLog(`${ev.status === 'ok' ? '✓' : '⚠'} ${ev.message}`, cls)
434
- if (ev.file) appendLog(` → ${ev.file}`, 'info')
435
- if (ev.hot) appendLog(` ${t('hotReload')}`, 'ok')
436
- else if (ev.status === 'ok') appendLog(` ${t('needRestart')}`, 'warn')
437
- if (ev.steps) ev.steps.forEach(s => appendLog(` · ${s}`, 'info'))
438
- }
439
- else if (ev.type === 'error') appendLog(`✗ ${ev.message}`, 'err')
440
- else if (ev.type === 'done') {
441
- appendLog(ev.success ? `\n✓ ${t('configSuccess')}` : `\n✗ ${t('configFailed')}`, ev.success ? 'ok' : 'err')
442
- if (ev.dashboardUrl) {
443
- appendLog(`\n→ ${ev.dashboardUrl}`, 'ok')
444
- window.open(ev.dashboardUrl, '_blank')
445
- }
1186
+ const toolSelect = document.getElementById('conversation-tool-select')
1187
+ if (toolSelect && state.currentConversation) {
1188
+ toolSelect.onchange = async (event) => {
1189
+ await api(`workspace/conversations/${state.currentConversation.id}`, {
1190
+ method: 'PATCH',
1191
+ headers: { 'Content-Type': 'application/json' },
1192
+ body: JSON.stringify({ toolId: event.target.value }),
1193
+ }).catch((error) => alert(error.message))
1194
+ await refreshWorkspace(true)
446
1195
  }
447
- })
1196
+ }
448
1197
 
449
- document.getElementById('console-section').classList.remove('busy')
450
- busy = false
451
- loadTools()
1198
+ const list = document.getElementById('message-list')
1199
+ if (list) list.scrollTop = list.scrollHeight
452
1200
  }
453
1201
 
454
- async function doClaudeProxyStart() {
455
- try {
456
- await api('claude-proxy/start', { method: 'POST' })
457
- loadTools()
458
- } catch (e) { alert('启动失败: ' + e.message) }
1202
+ async function refreshWorkspace(keepConversation) {
1203
+ state.workspace = await api('workspace/state').catch(() => state.workspace)
1204
+ if (keepConversation && state.currentConversationId) {
1205
+ await loadConversation(state.currentConversationId)
1206
+ }
1207
+ render()
459
1208
  }
460
1209
 
461
- async function doClaudeProxyStop() {
462
- try {
463
- await api('claude-proxy/stop', { method: 'POST' })
464
- loadTools()
465
- } catch (e) { alert('停止失败: ' + e.message) }
1210
+ async function createConversation() {
1211
+ const title = prompt('Conversation title', 'New Conversation')
1212
+ if (title == null) return
1213
+ const toolId = state.currentConversation?.toolId || 'codex'
1214
+ const payload = await api('workspace/conversations', {
1215
+ method: 'POST',
1216
+ headers: { 'Content-Type': 'application/json' },
1217
+ body: JSON.stringify({ title, toolId }),
1218
+ })
1219
+ state.currentConversationId = payload.conversation.id
1220
+ await loadConversation(payload.conversation.id)
1221
+ await refreshWorkspace(true)
1222
+ setRoute('workspace')
466
1223
  }
467
1224
 
468
- async function doLaunchTool(id) {
469
- try {
470
- await api('tool/launch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ toolId: id }) })
471
- } catch {}
1225
+ async function openConversation(id) {
1226
+ state.currentConversationId = id
1227
+ await loadConversation(id)
1228
+ if (state.route !== 'workspace') state.route = 'workspace'
1229
+ render()
472
1230
  }
473
1231
 
474
- async function doResetTool(id, name) {
475
- if (!confirm(t('confirmReset'))) return
476
- try {
477
- await api('tool/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ toolId: id }) })
478
- loadTools()
479
- } catch (e) { alert(e.message) }
1232
+ async function sendConversationMessage() {
1233
+ const input = document.getElementById('conversation-input')
1234
+ if (!input) return
1235
+ const content = input.value.trim()
1236
+ if (!content || !state.currentConversationId) return
1237
+ input.value = ''
1238
+ await api(`workspace/conversations/${state.currentConversationId}/messages`, {
1239
+ method: 'POST',
1240
+ headers: { 'Content-Type': 'application/json' },
1241
+ body: JSON.stringify({ content }),
1242
+ }).catch((error) => alert(error.message))
1243
+ await refreshWorkspace(true)
480
1244
  }
481
1245
 
482
- async function doInstallTool(id, name) {
483
- if (busy) return
484
- busy = true
485
- openConsole(`${t('install')}: ${name}`)
486
- document.getElementById('console-section').classList.add('busy')
1246
+ async function renameConversation() {
1247
+ if (!state.currentConversation) return
1248
+ const title = prompt('Rename conversation', state.currentConversation.title)
1249
+ if (title == null) return
1250
+ await api(`workspace/conversations/${state.currentConversation.id}`, {
1251
+ method: 'PATCH',
1252
+ headers: { 'Content-Type': 'application/json' },
1253
+ body: JSON.stringify({ title }),
1254
+ }).catch((error) => alert(error.message))
1255
+ await refreshWorkspace(true)
1256
+ }
487
1257
 
488
- await streamSSE('/api/tool/install', { toolId: id }, (ev) => {
489
- if (ev.type === 'progress') appendLog(ev.message, 'info')
490
- else if (ev.type === 'output') appendLogRaw(ev.text)
491
- else if (ev.type === 'done') {
492
- appendLog(ev.success ? `\n✓ ${t('installSuccess')}` : `\n✗ ${t('installFailed')}`, ev.success ? 'ok' : 'err')
493
- }
494
- })
1258
+ async function togglePinConversation() {
1259
+ if (!state.currentConversation) return
1260
+ await api(`workspace/conversations/${state.currentConversation.id}`, {
1261
+ method: 'PATCH',
1262
+ headers: { 'Content-Type': 'application/json' },
1263
+ body: JSON.stringify({ pinned: !state.currentConversation.pinned }),
1264
+ }).catch((error) => alert(error.message))
1265
+ await refreshWorkspace(true)
1266
+ }
495
1267
 
496
- document.getElementById('console-section').classList.remove('busy')
497
- busy = false
498
- loadTools()
499
- }
500
-
501
- async function doUpgrade() {
502
- if (busy) return
503
- busy = true
504
- openConsole(t('upgrade'))
505
- document.getElementById('console-section').classList.add('busy')
506
-
507
- await streamSSE('/api/upgrade', {}, (ev) => {
508
- if (ev.type === 'tool') {
509
- const cls = ev.status === 'ok' ? 'ok' : ev.status === 'error' ? 'err' : ev.status === 'not-installed' ? 'warn' : 'info'
510
- const msg = ev.status === 'not-installed' ? `${ev.name}: ${t('notInstalled')}`
511
- : ev.status === 'upgrading' ? `${ev.name}: ${t('upgrade')}... (${ev.localVer || '?'})`
512
- : ev.status === 'ok' ? `✓ ${ev.name}: ${ev.localVer || '?'} → ${ev.newVer || 'latest'}`
513
- : `✗ ${ev.name}: ${t('configFailed')}`
514
- appendLog(msg, cls)
515
- } else if (ev.type === 'output') { appendLogRaw(ev.text) }
516
- else if (ev.type === 'done') { appendLog(`\n✓ ${t('configSuccess')}`, 'ok') }
1268
+ function clearTaskForm() {
1269
+ ;['task-title', 'task-prompt', 'task-schedule'].forEach((id) => {
1270
+ const field = document.getElementById(id)
1271
+ if (field) field.value = ''
517
1272
  })
1273
+ const model = document.getElementById('task-model-override')
1274
+ if (model) model.value = ''
1275
+ delete window.__editingTaskId
1276
+ }
1277
+
1278
+ async function saveTask() {
1279
+ const payload = {
1280
+ title: document.getElementById('task-title').value.trim(),
1281
+ prompt: document.getElementById('task-prompt').value.trim(),
1282
+ schedule: document.getElementById('task-schedule').value.trim(),
1283
+ modelOverride: document.getElementById('task-model-override').value,
1284
+ active: true,
1285
+ }
1286
+ if (window.__editingTaskId) payload.id = window.__editingTaskId
1287
+ const route = payload.id ? `workspace/tasks/${payload.id}` : 'workspace/tasks'
1288
+ const method = payload.id ? 'PATCH' : 'POST'
1289
+ await api(route, {
1290
+ method,
1291
+ headers: { 'Content-Type': 'application/json' },
1292
+ body: JSON.stringify(payload),
1293
+ }).catch((error) => alert(error.message))
1294
+ clearTaskForm()
1295
+ await refreshWorkspace(false)
1296
+ setRoute('tasks')
1297
+ }
518
1298
 
519
- document.getElementById('console-section').classList.remove('busy')
520
- busy = false
521
- loadTools()
1299
+ function editTask(id) {
1300
+ const task = (state.workspace?.scheduledTasks || []).find((item) => item.id === id)
1301
+ if (!task) return
1302
+ document.getElementById('task-title').value = task.title
1303
+ document.getElementById('task-prompt').value = task.prompt
1304
+ document.getElementById('task-schedule').value = task.schedule
1305
+ document.getElementById('task-model-override').value = task.modelOverride || ''
1306
+ window.__editingTaskId = task.id
522
1307
  }
523
1308
 
524
- async function doUpgradeTool(id, name) {
525
- if (busy) return
526
- busy = true
527
- openConsole(`${t('upgradeOne')}: ${name}`)
528
- document.getElementById('console-section').classList.add('busy')
1309
+ async function deleteTask(id) {
1310
+ if (!confirm('Delete this task?')) return
1311
+ await api(`workspace/tasks/${id}`, { method: 'DELETE' }).catch((error) => alert(error.message))
1312
+ await refreshWorkspace(false)
1313
+ setRoute('tasks')
1314
+ }
529
1315
 
530
- let upgraded = false
531
- await streamSSE('/api/tool/upgrade', { toolId: id }, (ev) => {
532
- if (ev.type === 'progress') appendLog(ev.message, 'info')
533
- else if (ev.type === 'output') appendLogRaw(ev.text)
534
- else if (ev.type === 'done') {
535
- upgraded = ev.success
536
- appendLog(ev.success ? `\n✓ ${t('configSuccess')}` : `\n✗ ${t('configFailed')}`, ev.success ? 'ok' : 'err')
537
- }
538
- })
1316
+ async function runTask(id) {
1317
+ await api(`workspace/tasks/${id}`, { method: 'POST' }).catch((error) => alert(error.message))
1318
+ await refreshWorkspace(false)
1319
+ setRoute('tasks')
1320
+ }
539
1321
 
540
- document.getElementById('console-section').classList.remove('busy')
541
- busy = false
1322
+ async function saveRuntimeConfig() {
1323
+ const payload = {
1324
+ apiKey: document.getElementById('runtime-api-key').value.trim(),
1325
+ baseUrl: document.getElementById('runtime-base-url').value.trim(),
1326
+ model: document.getElementById('runtime-model').value,
1327
+ }
1328
+ await api('workspace/api-config', {
1329
+ method: 'POST',
1330
+ headers: { 'Content-Type': 'application/json' },
1331
+ body: JSON.stringify(payload),
1332
+ }).catch((error) => alert(error.message))
1333
+ await refreshWorkspace(false)
1334
+ setRoute('tasks')
1335
+ }
542
1336
 
543
- if (upgraded && id === 'holysheep') {
544
- appendLog(`\n${lang === 'zh' ? '⚠ HolySheep CLI 已升级,需要重启 WebUI 服务。正在重启...' : '⚠ HolySheep CLI upgraded. Restarting WebUI...'}`, 'warn')
545
- // 调用后端重启,3 秒后刷新页面
546
- try { await fetch('/api/restart', { method: 'POST' }) } catch {}
547
- setTimeout(() => location.reload(), 4000)
1337
+ function copyLoginKey() {
1338
+ const key = state.workspace?.holySheepApi?.apiKey
1339
+ if (!key) {
1340
+ alert('Login first so the workspace can seed the HolySheep API config.')
548
1341
  return
549
1342
  }
550
- loadTools()
551
- }
552
-
553
- async function doRollbackTool(id, name) {
554
- if (busy) return
555
- if (!confirm(lang === 'zh' ? `确认回退 ${name} 到上一个版本?` : `Rollback ${name} to previous version?`)) return
556
- busy = true
557
- openConsole(`${t('rollback')}: ${name}`)
558
- document.getElementById('console-section').classList.add('busy')
559
-
560
- let success = false
561
- await streamSSE('/api/tool/rollback', { toolId: id }, (ev) => {
562
- if (ev.type === 'progress') appendLog(ev.message, 'info')
563
- else if (ev.type === 'output') appendLogRaw(ev.text)
564
- else if (ev.type === 'done') {
565
- success = ev.success
566
- appendLog(ev.success ? `\n✓ ${lang === 'zh' ? '回退成功' : 'Rollback succeeded'}` : `\n✗ ${lang === 'zh' ? '回退失败' : 'Rollback failed'}`, ev.success ? 'ok' : 'err')
567
- }
568
- })
1343
+ document.getElementById('runtime-api-key').value = key
1344
+ }
569
1345
 
570
- document.getElementById('console-section').classList.remove('busy')
571
- busy = false
572
- loadTools()
1346
+ async function launchTool(toolId) {
1347
+ const result = await api('tool/launch', {
1348
+ method: 'POST',
1349
+ headers: { 'Content-Type': 'application/json' },
1350
+ body: JSON.stringify({ toolId }),
1351
+ }).catch((error) => alert(error.message))
1352
+ if (result?.url) alert(`Opened: ${result.url}`)
573
1353
  }
574
1354
 
575
- // ── Console ──────────────────────────────────────────────────────────────────
576
- function openConsole(title) {
577
- const area = document.getElementById('console-section')
578
- document.getElementById('console-title').textContent = title || t('log')
579
- document.getElementById('console-output').innerHTML = ''
580
- area.classList.add('open')
581
- area.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
1355
+ async function launchCurrentTool() {
1356
+ if (!state.currentConversation?.toolId) return
1357
+ return launchTool(state.currentConversation.toolId)
582
1358
  }
583
1359
 
584
- function closeConsole() {
585
- document.getElementById('console-section').classList.remove('open')
1360
+ function openConsole(title) {
1361
+ document.getElementById('console-title').textContent = title
1362
+ document.getElementById('console-log').textContent = ''
1363
+ document.getElementById('console-drawer').classList.add('open')
586
1364
  }
587
1365
 
588
- function appendLog(msg, cls) {
589
- const el = document.getElementById('console-output')
590
- el.innerHTML += `<span class="${cls || ''}">${esc(msg)}</span>\n`
591
- el.scrollTop = el.scrollHeight
1366
+ function appendConsole(text, klass) {
1367
+ const line = document.createElement('div')
1368
+ if (klass) line.className = klass
1369
+ line.textContent = text
1370
+ const log = document.getElementById('console-log')
1371
+ log.appendChild(line)
1372
+ log.scrollTop = log.scrollHeight
592
1373
  }
593
1374
 
594
- function appendLogRaw(text) {
595
- const el = document.getElementById('console-output')
596
- el.innerHTML += esc(text)
597
- el.scrollTop = el.scrollHeight
1375
+ document.getElementById('console-close').onclick = () => {
1376
+ document.getElementById('console-drawer').classList.remove('open')
598
1377
  }
599
1378
 
600
- // ── SSE helper ───────────────────────────────────────────────────────────────
601
- async function streamSSE(url, body, onEvent) {
602
- const res = await fetch(url, {
1379
+ async function consumeSse(path, body, title) {
1380
+ openConsole(title)
1381
+ const response = await fetch(`/api/${path}`, {
603
1382
  method: 'POST',
604
1383
  headers: { 'Content-Type': 'application/json' },
605
1384
  body: JSON.stringify(body),
606
1385
  })
607
- const reader = res.body.getReader()
1386
+ const reader = response.body.getReader()
608
1387
  const decoder = new TextDecoder()
609
1388
  let buffer = ''
610
1389
  while (true) {
611
- const { done, value } = await reader.read()
1390
+ const { value, done } = await reader.read()
612
1391
  if (done) break
613
1392
  buffer += decoder.decode(value, { stream: true })
614
- const parts = buffer.split('\n\n')
615
- buffer = parts.pop()
616
- for (const part of parts) {
617
- const line = part.split('\n').find(l => l.startsWith('data: '))
1393
+ const chunks = buffer.split('\n\n')
1394
+ buffer = chunks.pop()
1395
+ for (const chunk of chunks) {
1396
+ const line = chunk.split('\n').find((entry) => entry.startsWith('data:'))
618
1397
  if (!line) continue
619
- try { onEvent(JSON.parse(line.slice(6))) } catch {}
1398
+ const payload = JSON.parse(line.slice(5).trim())
1399
+ if (payload.type === 'output') appendConsole(payload.text, '')
1400
+ else if (payload.type === 'progress') appendConsole(payload.message || JSON.stringify(payload), payload.status === 'error' ? 'error' : payload.status === 'ok' ? 'ok' : 'warn')
1401
+ else if (payload.type === 'done') appendConsole('Done.', payload.success === false ? 'error' : 'ok')
1402
+ else appendConsole(JSON.stringify(payload), '')
620
1403
  }
621
1404
  }
622
1405
  }
623
1406
 
624
- // ── Environment section ──────────────────────────────────────────────────────
625
- async function loadEnv() {
626
- const el = document.getElementById('env-section')
627
- el.innerHTML = `<div class="section-title">${t('env')}</div><div class="card"><span class="loading">${t('checking')}</span></div>`
628
-
629
- const data = await api('env')
630
- const vars = data.vars || {}
631
- const rcFiles = data.rcFiles || []
632
-
633
- let rows = ''
634
- for (const [k, v] of Object.entries(vars)) {
635
- const isSet = v !== null
636
- rows += `<div class="env-row">
637
- <span class="dot ${isSet ? 'dot-ok' : 'dot-gray'}" style="width:8px;height:8px"></span>
638
- <span class="env-key">${k}</span>
639
- <span class="env-val" style="color:var(${isSet ? '--success' : '--text2'})">${isSet ? t('set') : t('notSet')}</span>
640
- </div>`
641
- }
642
-
643
- let rcInfo = ''
644
- if (rcFiles.length) {
645
- rcInfo = `<div class="env-rc">${t('shellConfig')}: ${rcFiles.map(f =>
646
- `<span class="mono">${esc(f.path)}</span> (${f.hasManagedBlock ? t('managedBlock') : t('noManagedBlock')})`
647
- ).join(', ')}</div>`
648
- }
1407
+ function configureTool(toolId) {
1408
+ consumeSse('tool/configure', { toolId }, `Configure ${toolNames[toolId] || toolId}`).then(() => refreshWorkspace(false))
1409
+ }
649
1410
 
650
- el.innerHTML = `<div class="section-title">
651
- <span>${t('env')}</span>
652
- <button class="btn btn-outline btn-sm" onclick="doCleanEnv(this)">${t('cleanConflicts')}</button>
653
- </div>
654
- <div class="card">
655
- <div class="env-table">${rows}</div>
656
- ${rcInfo}
657
- </div>`
658
- }
659
-
660
- async function doCleanEnv(btn) {
661
- btn.disabled = true
662
- btn.textContent = t('cleaning')
663
- try {
664
- const r = await api('env/clean', { method: 'POST' })
665
- btn.textContent = `✓ ${t('cleanDone')}`
666
- setTimeout(() => loadEnv(), 1500)
667
- } catch (e) {
668
- btn.textContent = e.message
669
- }
1411
+ async function login() {
1412
+ const apiKey = document.getElementById('login-api-key').value.trim()
1413
+ await api('login', {
1414
+ method: 'POST',
1415
+ headers: { 'Content-Type': 'application/json' },
1416
+ body: JSON.stringify({ apiKey }),
1417
+ }).catch((error) => alert(error.message))
1418
+ await loadBaseState()
1419
+ render()
670
1420
  }
671
1421
 
672
- // ── Footer ───────────────────────────────────────────────────────────────────
673
- function renderFooter() {
674
- document.getElementById('footer-section').innerHTML = `<div class="footer">
675
- <div class="footer-brand">🐑 <span>HolySheep</span></div>
676
- <div class="footer-links">
677
- <a href="https://holysheep.ai" target="_blank">${t('docs')}</a>
678
- <a href="https://holysheep.ai/app/recharge" target="_blank">${t('recharge')}</a>
679
- <a href="https://holysheep.ai/register" target="_blank">${lang === 'zh' ? '注册' : 'Register'}</a>
680
- <a href="https://holysheep.ai/pricing" target="_blank">${t('pricing')}</a>
681
- </div>
682
- <div class="footer-sub">${t('slogan')}</div>
683
- </div>`
1422
+ async function logout() {
1423
+ await api('logout', { method: 'POST' }).catch((error) => alert(error.message))
1424
+ await loadBaseState()
1425
+ render()
684
1426
  }
685
1427
 
686
- // ── Util ─────────────────────────────────────────────────────────────────────
687
- function esc(s) {
688
- if (!s) return ''
689
- const d = document.createElement('div')
690
- d.textContent = String(s)
691
- return d.innerHTML
1428
+ document.getElementById('refresh-btn').onclick = async () => {
1429
+ await loadBaseState()
1430
+ render()
692
1431
  }
693
1432
 
694
- function fmtNum(n) {
695
- if (n >= 10000) return Math.floor(n).toLocaleString()
696
- if (n >= 100) return n.toFixed(1)
697
- if (n >= 1) return n.toFixed(2)
698
- return n.toFixed(4)
1433
+ window.addEventListener('hashchange', () => {
1434
+ state.route = routeFromHash()
1435
+ render()
1436
+ })
1437
+
1438
+ async function bootstrap() {
1439
+ state.route = routeFromHash()
1440
+ await loadBaseState()
1441
+ render()
699
1442
  }
1443
+
1444
+ bootstrap()
700
1445
  </script>
701
1446
  </body>
702
1447
  </html>