remote-coder 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. app/__init__.py +3 -0
  2. app/admin/__init__.py +0 -0
  3. app/admin/advanced_settings.py +88 -0
  4. app/admin/database_browser.py +301 -0
  5. app/admin/router.py +528 -0
  6. app/admin/static/i18n.js +401 -0
  7. app/admin/static/icons/advanced.svg +8 -0
  8. app/admin/static/icons/database.svg +5 -0
  9. app/admin/static/icons/download.svg +3 -0
  10. app/admin/static/icons/home.svg +4 -0
  11. app/admin/static/icons/logs.svg +3 -0
  12. app/admin/static/icons/projects.svg +5 -0
  13. app/admin/static/summary.js +73 -0
  14. app/admin/templates/admin.html +511 -0
  15. app/admin/templates/advanced.html +635 -0
  16. app/admin/templates/database.html +880 -0
  17. app/admin/templates/logs.html +686 -0
  18. app/admin/templates/projects.html +878 -0
  19. app/ai/__init__.py +0 -0
  20. app/ai/base.py +129 -0
  21. app/ai/claude.py +20 -0
  22. app/ai/codex.py +34 -0
  23. app/ai/factory.py +27 -0
  24. app/ai/gemini.py +20 -0
  25. app/ai/model_catalog.py +47 -0
  26. app/ai/usage.py +134 -0
  27. app/cli.py +238 -0
  28. app/config.py +130 -0
  29. app/git/__init__.py +0 -0
  30. app/git/ai_commit.py +88 -0
  31. app/git/branch_naming.py +21 -0
  32. app/git/commit_message.py +279 -0
  33. app/git/service.py +669 -0
  34. app/jobs/__init__.py +0 -0
  35. app/jobs/manager.py +770 -0
  36. app/jobs/schemas.py +116 -0
  37. app/jobs/store.py +334 -0
  38. app/main.py +265 -0
  39. app/models.py +20 -0
  40. app/monitoring/__init__.py +10 -0
  41. app/monitoring/code.py +161 -0
  42. app/monitoring/events.py +33 -0
  43. app/monitoring/git.py +103 -0
  44. app/monitoring/log_buffer.py +245 -0
  45. app/monitoring/memory.py +19 -0
  46. app/monitoring/model.py +598 -0
  47. app/projects/__init__.py +19 -0
  48. app/projects/registry.py +384 -0
  49. app/security/__init__.py +0 -0
  50. app/security/auth.py +19 -0
  51. app/system_startup.py +34 -0
  52. app/telegram/__init__.py +0 -0
  53. app/telegram/bot_instances.py +67 -0
  54. app/telegram/commands/__init__.py +64 -0
  55. app/telegram/commands/base.py +222 -0
  56. app/telegram/commands/branch.py +366 -0
  57. app/telegram/commands/clear_stop.py +221 -0
  58. app/telegram/commands/fix.py +219 -0
  59. app/telegram/commands/model.py +93 -0
  60. app/telegram/commands/monitor.py +185 -0
  61. app/telegram/commands/registry.py +110 -0
  62. app/telegram/commands/status.py +243 -0
  63. app/telegram/commands/system.py +201 -0
  64. app/telegram/confirmations.py +36 -0
  65. app/telegram/conversation.py +789 -0
  66. app/telegram/i18n.py +742 -0
  67. app/telegram/model_preferences.py +53 -0
  68. app/telegram/notifier.py +387 -0
  69. app/telegram/parser.py +267 -0
  70. app/telegram/webhook.py +988 -0
  71. app/telegram/webhook_registration.py +172 -0
  72. app/tunnel.py +104 -0
  73. remote_coder-0.4.1.dist-info/METADATA +520 -0
  74. remote_coder-0.4.1.dist-info/RECORD +78 -0
  75. remote_coder-0.4.1.dist-info/WHEEL +5 -0
  76. remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
  77. remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
  78. remote_coder-0.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,878 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title data-i18n="title.projects">Remote AI Coder - Projects</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f1419;
10
+ --bg-elevated: #1a2332;
11
+ --surface: #243044;
12
+ --border: rgba(255, 255, 255, 0.08);
13
+ --text: #e8edf4;
14
+ --text-muted: #8b9cb3;
15
+ --accent: #3d9cf5;
16
+ --accent-dim: rgba(61, 156, 245, 0.15);
17
+ --success: #34c759;
18
+ --success-bg: rgba(52, 199, 89, 0.12);
19
+ --danger: #ff453a;
20
+ --danger-bg: rgba(255, 69, 58, 0.12);
21
+ --warning: #ffcc00;
22
+ --radius: 12px;
23
+ --radius-sm: 8px;
24
+ --shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
25
+ --font: "Segoe UI", system-ui, -apple-system, sans-serif;
26
+ --focus: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
27
+ }
28
+
29
+ *, *::before, *::after { box-sizing: border-box; }
30
+
31
+ body {
32
+ margin: 0;
33
+ min-height: 100vh;
34
+ font-family: var(--font);
35
+ font-size: 15px;
36
+ line-height: 1.5;
37
+ color: var(--text);
38
+ background: radial-gradient(ellipse 120% 80% at 50% -20%, #1e3a5f 0%, var(--bg) 55%);
39
+ padding: 1.25rem 1rem 2.5rem;
40
+ }
41
+
42
+ .wrap { max-width: 72rem; margin: 0 auto; }
43
+
44
+ .app-header {
45
+ margin-bottom: 1.5rem;
46
+ padding: 1.25rem 1.35rem;
47
+ background: linear-gradient(135deg, var(--bg-elevated) 0%, var(--surface) 100%);
48
+ border: 1px solid var(--border);
49
+ border-radius: var(--radius);
50
+ box-shadow: var(--shadow);
51
+ }
52
+
53
+ .app-header h1 {
54
+ margin: 0 0 0.35rem;
55
+ font-size: 1.5rem;
56
+ font-weight: 700;
57
+ letter-spacing: -0.02em;
58
+ }
59
+
60
+ .app-header .tagline {
61
+ margin: 0;
62
+ color: var(--text-muted);
63
+ font-size: 0.9rem;
64
+ }
65
+
66
+ .localhost-pill {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ gap: 0.35rem;
70
+ margin-top: 0.75rem;
71
+ padding: 0.35rem 0.65rem;
72
+ font-size: 0.8rem;
73
+ font-weight: 600;
74
+ color: var(--warning);
75
+ background: rgba(255, 204, 0, 0.1);
76
+ border: 1px solid rgba(255, 204, 0, 0.25);
77
+ border-radius: 999px;
78
+ }
79
+
80
+ .header-row {
81
+ display: flex;
82
+ flex-wrap: wrap;
83
+ align-items: flex-start;
84
+ justify-content: space-between;
85
+ gap: 1rem;
86
+ }
87
+
88
+ .header-icon-nav {
89
+ display: flex;
90
+ flex-shrink: 0;
91
+ gap: 0.35rem;
92
+ align-items: center;
93
+ }
94
+
95
+ .header-icon-link {
96
+ display: flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ width: 2.5rem;
100
+ height: 2.5rem;
101
+ border-radius: var(--radius-sm);
102
+ border: 1px solid var(--border);
103
+ background: rgba(0, 0, 0, 0.15);
104
+ transition: background 0.15s, border-color 0.15s;
105
+ }
106
+
107
+ .header-icon-link:hover {
108
+ background: #2d3d54;
109
+ border-color: rgba(255, 255, 255, 0.12);
110
+ }
111
+
112
+ .header-icon-link:focus-visible {
113
+ outline: none;
114
+ box-shadow: var(--focus);
115
+ }
116
+
117
+ .header-icon-link.is-current {
118
+ border-color: rgba(61, 156, 245, 0.45);
119
+ background: var(--accent-dim);
120
+ }
121
+
122
+ .header-icon-link img {
123
+ display: block;
124
+ width: 1.35rem;
125
+ height: 1.35rem;
126
+ }
127
+
128
+ #global-msg {
129
+ margin-bottom: 1rem;
130
+ padding: 0.65rem 1rem;
131
+ border-radius: var(--radius-sm);
132
+ font-size: 0.9rem;
133
+ display: none;
134
+ }
135
+
136
+ #global-msg.is-visible { display: block; }
137
+
138
+ #global-msg.msg-err {
139
+ color: #ffb4b0;
140
+ background: var(--danger-bg);
141
+ border: 1px solid rgba(255, 69, 58, 0.35);
142
+ }
143
+
144
+ #global-msg.msg-ok {
145
+ color: #9ee6b0;
146
+ background: var(--success-bg);
147
+ border: 1px solid rgba(52, 199, 89, 0.35);
148
+ }
149
+
150
+ #global-msg.msg-info {
151
+ color: var(--text-muted);
152
+ background: var(--accent-dim);
153
+ border: 1px solid rgba(61, 156, 245, 0.25);
154
+ }
155
+
156
+ .summary-grid {
157
+ display: grid;
158
+ grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
159
+ gap: 0.75rem;
160
+ margin-bottom: 1.25rem;
161
+ }
162
+
163
+ .stat-card {
164
+ padding: 1rem 1.1rem;
165
+ background: var(--bg-elevated);
166
+ border: 1px solid var(--border);
167
+ border-radius: var(--radius-sm);
168
+ }
169
+
170
+ .stat-card .label {
171
+ margin: 0;
172
+ font-size: 0.72rem;
173
+ font-weight: 600;
174
+ text-transform: uppercase;
175
+ letter-spacing: 0.06em;
176
+ color: var(--text-muted);
177
+ }
178
+
179
+ .stat-card .value {
180
+ margin: 0.25rem 0 0;
181
+ font-size: 1.35rem;
182
+ font-weight: 700;
183
+ color: var(--text);
184
+ }
185
+
186
+ .stat-card .sub {
187
+ margin: 0.2rem 0 0;
188
+ font-size: 0.78rem;
189
+ color: var(--text-muted);
190
+ word-break: break-all;
191
+ }
192
+
193
+ section.card {
194
+ margin-bottom: 1.25rem;
195
+ padding: 1.25rem 1.35rem;
196
+ background: var(--bg-elevated);
197
+ border: 1px solid var(--border);
198
+ border-radius: var(--radius);
199
+ box-shadow: var(--shadow);
200
+ }
201
+
202
+ section.card h2 {
203
+ margin: 0 0 0.5rem;
204
+ font-size: 1.05rem;
205
+ font-weight: 700;
206
+ }
207
+
208
+ section.card > .lead {
209
+ margin: 0 0 1rem;
210
+ color: var(--text-muted);
211
+ font-size: 0.88rem;
212
+ }
213
+
214
+ section.card > .lead code {
215
+ padding: 0.12rem 0.35rem;
216
+ font-size: 0.82em;
217
+ background: var(--surface);
218
+ border-radius: 4px;
219
+ border: 1px solid var(--border);
220
+ }
221
+
222
+ .table-wrap {
223
+ overflow-x: auto;
224
+ margin-top: 0.5rem;
225
+ border-radius: var(--radius-sm);
226
+ border: 1px solid var(--border);
227
+ }
228
+
229
+ table.data {
230
+ width: 100%;
231
+ border-collapse: collapse;
232
+ font-size: 0.88rem;
233
+ }
234
+
235
+ table.data caption {
236
+ caption-side: top;
237
+ text-align: left;
238
+ padding: 0.65rem 0.85rem;
239
+ font-size: 0.82rem;
240
+ color: var(--text-muted);
241
+ background: var(--surface);
242
+ border-bottom: 1px solid var(--border);
243
+ }
244
+
245
+ table.data th,
246
+ table.data td {
247
+ padding: 0.65rem 0.75rem;
248
+ text-align: left;
249
+ vertical-align: top;
250
+ border-bottom: 1px solid var(--border);
251
+ }
252
+
253
+ table.data th {
254
+ font-weight: 600;
255
+ font-size: 0.72rem;
256
+ text-transform: uppercase;
257
+ letter-spacing: 0.05em;
258
+ color: var(--text-muted);
259
+ background: rgba(0, 0, 0, 0.2);
260
+ }
261
+
262
+ table.data tbody tr:last-child td { border-bottom: none; }
263
+
264
+ table.data tbody tr:hover td {
265
+ background: rgba(61, 156, 245, 0.04);
266
+ }
267
+
268
+ .path-cell {
269
+ max-width: 14rem;
270
+ font-family: ui-monospace, monospace;
271
+ font-size: 0.78rem;
272
+ word-break: break-all;
273
+ color: var(--text-muted);
274
+ }
275
+
276
+ .badge {
277
+ display: inline-block;
278
+ padding: 0.15rem 0.45rem;
279
+ font-size: 0.68rem;
280
+ font-weight: 700;
281
+ text-transform: uppercase;
282
+ letter-spacing: 0.04em;
283
+ border-radius: 4px;
284
+ margin-right: 0.25rem;
285
+ margin-bottom: 0.15rem;
286
+ }
287
+
288
+ .badge-default {
289
+ color: #7ec8ff;
290
+ background: var(--accent-dim);
291
+ border: 1px solid rgba(61, 156, 245, 0.35);
292
+ }
293
+
294
+ .badge-on {
295
+ color: #9ee6b0;
296
+ background: var(--success-bg);
297
+ border: 1px solid rgba(52, 199, 89, 0.35);
298
+ }
299
+
300
+ .badge-off {
301
+ color: var(--text-muted);
302
+ background: rgba(255, 255, 255, 0.05);
303
+ border: 1px solid var(--border);
304
+ }
305
+
306
+ .btn-row {
307
+ display: flex;
308
+ flex-wrap: wrap;
309
+ gap: 0.4rem;
310
+ }
311
+
312
+ button {
313
+ font-family: inherit;
314
+ font-size: 0.82rem;
315
+ font-weight: 600;
316
+ padding: 0.45rem 0.75rem;
317
+ border-radius: var(--radius-sm);
318
+ border: 1px solid var(--border);
319
+ background: var(--surface);
320
+ color: var(--text);
321
+ cursor: pointer;
322
+ transition: background 0.15s, border-color 0.15s, opacity 0.15s;
323
+ }
324
+
325
+ button:hover:not(:disabled) {
326
+ background: #2d3d54;
327
+ border-color: rgba(255, 255, 255, 0.12);
328
+ }
329
+
330
+ button:focus-visible {
331
+ outline: none;
332
+ box-shadow: var(--focus);
333
+ }
334
+
335
+ button:disabled {
336
+ opacity: 0.45;
337
+ cursor: not-allowed;
338
+ }
339
+
340
+ button.btn-primary {
341
+ background: var(--accent);
342
+ border-color: transparent;
343
+ color: #0a1628;
344
+ }
345
+
346
+ button.btn-primary:hover:not(:disabled) {
347
+ background: #5aadf7;
348
+ }
349
+
350
+ button.btn-danger {
351
+ color: #ffb4b0;
352
+ background: var(--danger-bg);
353
+ border-color: rgba(255, 69, 58, 0.35);
354
+ }
355
+
356
+ button.btn-danger:hover:not(:disabled) {
357
+ background: rgba(255, 69, 58, 0.22);
358
+ }
359
+
360
+ button.btn-ghost {
361
+ background: transparent;
362
+ }
363
+
364
+ .form-mode {
365
+ display: inline-flex;
366
+ align-items: center;
367
+ gap: 0.5rem;
368
+ margin-bottom: 1rem;
369
+ padding: 0.45rem 0.75rem;
370
+ font-size: 0.82rem;
371
+ font-weight: 600;
372
+ border-radius: var(--radius-sm);
373
+ background: var(--surface);
374
+ border: 1px solid var(--border);
375
+ }
376
+
377
+ .form-mode.mode-add { color: var(--accent); }
378
+ .form-mode.mode-edit { color: var(--warning); }
379
+
380
+ label.field {
381
+ display: block;
382
+ margin-top: 0.85rem;
383
+ font-weight: 600;
384
+ font-size: 0.78rem;
385
+ text-transform: uppercase;
386
+ letter-spacing: 0.04em;
387
+ color: var(--text-muted);
388
+ }
389
+
390
+ label.field:first-of-type { margin-top: 0; }
391
+
392
+ .hint {
393
+ margin: 0.2rem 0 0;
394
+ font-size: 0.78rem;
395
+ font-weight: 400;
396
+ color: var(--text-muted);
397
+ text-transform: none;
398
+ letter-spacing: normal;
399
+ }
400
+
401
+ input, select {
402
+ width: 100%;
403
+ max-width: 100%;
404
+ margin-top: 0.35rem;
405
+ padding: 0.55rem 0.65rem;
406
+ font-family: inherit;
407
+ font-size: 0.95rem;
408
+ color: var(--text);
409
+ background: var(--bg);
410
+ border: 1px solid var(--border);
411
+ border-radius: var(--radius-sm);
412
+ }
413
+
414
+ input:focus, select:focus {
415
+ outline: none;
416
+ border-color: var(--accent);
417
+ box-shadow: 0 0 0 3px var(--accent-dim);
418
+ }
419
+
420
+ input:read-only {
421
+ opacity: 0.85;
422
+ cursor: default;
423
+ }
424
+
425
+ label.checkbox {
426
+ display: flex;
427
+ align-items: center;
428
+ gap: 0.5rem;
429
+ margin-top: 0.85rem;
430
+ font-weight: 600;
431
+ font-size: 0.9rem;
432
+ color: var(--text);
433
+ text-transform: none;
434
+ letter-spacing: normal;
435
+ }
436
+
437
+ label.checkbox input {
438
+ width: auto;
439
+ margin: 0;
440
+ }
441
+
442
+ .form-actions {
443
+ display: flex;
444
+ flex-wrap: wrap;
445
+ gap: 0.5rem;
446
+ margin-top: 1.1rem;
447
+ }
448
+
449
+ .optional-fields {
450
+ margin-top: 0.85rem;
451
+ padding: 0.65rem 0.85rem;
452
+ border: 1px solid var(--border);
453
+ border-radius: var(--radius-sm);
454
+ background: rgba(0, 0, 0, 0.15);
455
+ }
456
+
457
+ .optional-fields summary {
458
+ cursor: pointer;
459
+ font-weight: 600;
460
+ font-size: 0.9rem;
461
+ color: var(--text);
462
+ list-style-position: outside;
463
+ }
464
+
465
+ .optional-fields summary:focus-visible {
466
+ outline: none;
467
+ box-shadow: var(--focus);
468
+ border-radius: 4px;
469
+ }
470
+
471
+ .optional-fields[open] summary {
472
+ margin-bottom: 0.5rem;
473
+ }
474
+
475
+ .optional-fields .field:first-of-type {
476
+ margin-top: 0;
477
+ }
478
+
479
+ .empty-state {
480
+ padding: 1.5rem;
481
+ text-align: center;
482
+ color: var(--text-muted);
483
+ font-size: 0.9rem;
484
+ }
485
+
486
+ @media (max-width: 640px) {
487
+ .app-header h1 { font-size: 1.25rem; }
488
+ .path-cell { max-width: none; }
489
+ }
490
+ </style>
491
+ </head>
492
+ <body>
493
+ <div class="wrap">
494
+ <header class="app-header">
495
+ <div class="header-row">
496
+ <div>
497
+ <h1 data-i18n="projects.h1">Project registration</h1>
498
+ <p class="tagline" data-i18n-html="projects.tagline">Each entry is bound to <strong>one Telegram bot</strong> and a fixed repository.</p>
499
+ </div>
500
+ <nav class="header-icon-nav" aria-label="Page navigation" data-i18n-aria-label="nav.aria">
501
+ <a href="/" class="header-icon-link" title="Admin home" aria-label="Admin home" data-i18n-title="nav.home" data-i18n-aria-label="nav.home">
502
+ <img src="/admin-static/icons/home.svg" width="22" height="22" alt="" />
503
+ </a>
504
+ <a href="/projects" class="header-icon-link is-current" title="Projects" aria-label="Projects" data-i18n-title="nav.projects" data-i18n-aria-label="nav.projects">
505
+ <img src="/admin-static/icons/projects.svg" width="22" height="22" alt="" />
506
+ </a>
507
+ <a href="/advanced" class="header-icon-link" title="Advanced settings" aria-label="Advanced settings" data-i18n-title="nav.advanced" data-i18n-aria-label="nav.advanced">
508
+ <img src="/admin-static/icons/advanced.svg" width="22" height="22" alt="" />
509
+ </a>
510
+ <a href="/logs" class="header-icon-link" title="Server logs" aria-label="Server logs" data-i18n-title="nav.logs" data-i18n-aria-label="nav.logs">
511
+ <img src="/admin-static/icons/logs.svg" width="22" height="22" alt="" />
512
+ </a>
513
+ <a href="/database" class="header-icon-link" title="Data browser" aria-label="Data browser" data-i18n-title="nav.database" data-i18n-aria-label="nav.database">
514
+ <img src="/admin-static/icons/database.svg" width="22" height="22" alt="" />
515
+ </a>
516
+ </nav>
517
+ </div>
518
+ <span class="localhost-pill" aria-hidden="true" data-i18n="common.localOnly">Local only</span>
519
+ <p class="tagline" style="margin-top:0.65rem" data-i18n-html="common.localhostNote">This page is only available from <strong>127.0.0.1</strong>.</p>
520
+ </header>
521
+
522
+ <div id="global-msg" role="status" aria-live="polite"></div>
523
+
524
+ <div class="summary-grid" id="summary-grid" aria-label="Summary" data-i18n-aria-label="common.summaryAria"></div>
525
+
526
+ <section class="card" aria-labelledby="projects-heading">
527
+ <h2 id="projects-heading" data-i18n="projects.listHeading">Registered projects</h2>
528
+ <p class="lead" data-i18n-html="projects.listLead">Add a project or pick the fallback default. Saving is applied immediately to the running server's <code>BotInstanceManager</code>.</p>
529
+
530
+ <div class="table-wrap">
531
+ <table class="data" id="proj-table">
532
+ <thead>
533
+ <tr>
534
+ <th data-i18n="common.name">Name</th>
535
+ <th data-i18n="common.status">Status</th>
536
+ <th data-i18n="projects.colGitRoot">Git root</th>
537
+ <th data-i18n="common.worktree">Worktree</th>
538
+ <th data-i18n="common.model">Model</th>
539
+ <th>Telegram</th>
540
+ <th data-i18n="common.actions">Actions</th>
541
+ </tr>
542
+ </thead>
543
+ <tbody id="proj-tbody"></tbody>
544
+ </table>
545
+ </div>
546
+
547
+ <h2 style="margin-top:1.5rem;font-size:1.05rem" id="form-heading" data-i18n="projects.formHeading">Add / edit</h2>
548
+ <div class="form-mode mode-add" id="form-mode-banner" aria-live="polite" data-i18n="projects.addNew">Add new project</div>
549
+
550
+ <form id="proj-form">
551
+ <label class="field" for="f-name">
552
+ <span data-i18n="common.name">Name</span>
553
+ <span class="hint" data-i18n-html="projects.nameHint">Start with a letter or digit; <code>.</code> <code>_</code> <code>-</code> allowed</span>
554
+ </label>
555
+ <input id="f-name" name="name" required pattern="[A-Za-z0-9][A-Za-z0-9._-]*" autocomplete="off" />
556
+
557
+ <label class="field" for="f-root" data-i18n="projects.gitRootPath">Git root path</label>
558
+ <p class="hint" style="margin:0.35rem 0 0" data-i18n="projects.gitRootHint">Absolute path. The directory must exist to save.</p>
559
+ <input id="f-root" name="root_path" required autocomplete="off" />
560
+
561
+ <label class="field" for="f-wt" data-i18n="projects.worktreeBaseDir">Worktree base directory</label>
562
+ <p class="hint" style="margin:0.35rem 0 0" data-i18n="projects.worktreeHint">Created if missing.</p>
563
+ <input id="f-wt" name="worktree_base_dir" required autocomplete="off" />
564
+
565
+ <label class="field" for="f-model" data-i18n="common.defaultModel">Default model</label>
566
+ <select id="f-model" name="default_model">
567
+ <option value="claude">claude</option>
568
+ <option value="codex">codex</option>
569
+ <option value="gemini">gemini</option>
570
+ </select>
571
+
572
+ <label class="checkbox"><input type="checkbox" name="enabled" checked /> <span data-i18n="common.enabled">Enabled</span></label>
573
+
574
+ <label class="field" for="f-bot-token">
575
+ <span data-i18n="projects.botToken">Bot token</span>
576
+ <span class="hint" data-i18n-html="projects.botTokenHint">
577
+ API token from Telegram BotFather after <code>/newbot</code>. The plaintext is stored only in the registry file; lists and APIs show a masked value.
578
+ </span>
579
+ </label>
580
+ <input id="f-bot-token" name="bot_token" type="password" autocomplete="new-password" placeholder="123456789:AA…" />
581
+
582
+ <label class="field" for="f-chats" data-i18n="common.allowedChatIds">Allowed Chat IDs</label>
583
+ <p class="hint" style="margin:0.35rem 0 0" data-i18n="projects.chatIdsHint">Comma- or space-separated. At least one.</p>
584
+ <input id="f-chats" name="allowed_chat_ids" required autocomplete="off" placeholder="-1001234567890, 123456789" />
585
+
586
+ <details class="optional-fields">
587
+ <summary data-i18n="projects.optional">Optional fields</summary>
588
+ <label class="field" for="f-wh-secret">
589
+ <span data-i18n="projects.webhookSecret">Webhook secret (optional)</span>
590
+ <span class="hint" data-i18n-html="projects.webhookSecretHint">Telegram <code>secret_token</code>. Leave blank when editing to keep the existing value.</span>
591
+ </label>
592
+ <input id="f-wh-secret" name="webhook_secret" type="password" autocomplete="new-password" />
593
+
594
+ <label class="field" for="f-users" data-i18n="projects.allowedUserIdsOptional">Allowed User IDs (optional)</label>
595
+ <p class="hint" style="margin:0.35rem 0 0" data-i18n="projects.userIdsHint">Blank allows by chat ID only.</p>
596
+ <input id="f-users" name="allowed_user_ids" autocomplete="off" placeholder="123456789" />
597
+ </details>
598
+
599
+ <div class="form-actions">
600
+ <button type="submit" class="btn-primary" id="btn-submit" data-i18n="projects.btnAdd">Add</button>
601
+ <button type="button" class="btn-ghost" id="btn-clear" data-i18n="projects.btnClear">Clear form</button>
602
+ </div>
603
+ </form>
604
+ </section>
605
+ </div>
606
+
607
+ <script src="/admin-static/i18n.js"></script>
608
+ <script src="/admin-static/summary.js"></script>
609
+ <script>
610
+ const $ = (s) => document.querySelector(s);
611
+ const globalMsg = $("#global-msg");
612
+ let lastProjects = [];
613
+ let lastDefault = "";
614
+ let busy = false;
615
+
616
+ function escapeHtml(s) {
617
+ return String(s).replace(/[&<>"']/g, (c) => ({
618
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
619
+ }[c]));
620
+ }
621
+ function escapeAttr(s) {
622
+ return String(s).replace(/'/g, "&#39;");
623
+ }
624
+
625
+ function setGlobalMsg(kind, text) {
626
+ if (!text) {
627
+ globalMsg.textContent = "";
628
+ globalMsg.className = "";
629
+ return;
630
+ }
631
+ globalMsg.textContent = text;
632
+ globalMsg.className =
633
+ "is-visible " + (kind === "err" ? "msg-err" : kind === "ok" ? "msg-ok" : "msg-info");
634
+ }
635
+
636
+ async function parseApiError(r, rawText) {
637
+ const ct = (r.headers.get("content-type") || "").toLowerCase();
638
+ if (ct.includes("application/json")) {
639
+ try {
640
+ const j = JSON.parse(rawText);
641
+ if (j && typeof j.detail === "string") return j.detail;
642
+ if (Array.isArray(j.detail)) {
643
+ return j.detail.map((d) => (typeof d.msg === "string" ? d.msg : JSON.stringify(d))).join("\n");
644
+ }
645
+ } catch (_) { /* ignore */ }
646
+ }
647
+ return rawText || "Request failed (" + r.status + ")";
648
+ }
649
+
650
+ function setBusy(v) {
651
+ busy = v;
652
+ document.querySelectorAll("#proj-form button, #proj-table button").forEach((b) => {
653
+ b.disabled = v;
654
+ });
655
+ if (!v) {
656
+ document.querySelectorAll('#proj-table button[data-a="default"]').forEach((b) => {
657
+ if (b.dataset.n === lastDefault) b.disabled = true;
658
+ });
659
+ }
660
+ }
661
+
662
+ function updateFormModeBanner() {
663
+ const f = $("#proj-form");
664
+ const banner = $("#form-mode-banner");
665
+ const editing = f.name.readOnly;
666
+ banner.className = "form-mode " + (editing ? "mode-edit" : "mode-add");
667
+ banner.textContent = editing ? i18n.t("projects.editingPrefix") + f.name.value : i18n.t("projects.addNew");
668
+ }
669
+
670
+ function parseIdList(raw) {
671
+ if (!raw || !String(raw).trim()) return [];
672
+ return String(raw)
673
+ .split(/[\s,]+/)
674
+ .map((s) => s.trim())
675
+ .filter(Boolean)
676
+ .map((s) => parseInt(s, 10))
677
+ .filter((n) => !Number.isNaN(n));
678
+ }
679
+
680
+ function renderProjectTable(projectsPayload) {
681
+ const tb = $("#proj-tbody");
682
+ tb.innerHTML = "";
683
+ const tbl = $("#proj-table");
684
+ const oldCap = tbl.querySelector("caption");
685
+ if (oldCap) oldCap.remove();
686
+ const cap = document.createElement("caption");
687
+ cap.textContent = i18n.t("projects.captionDefault") + (lastDefault || i18n.t("common.none"));
688
+ tbl.prepend(cap);
689
+
690
+ const list = projectsPayload.projects || [];
691
+ if (!list.length) {
692
+ const tr = document.createElement("tr");
693
+ tr.innerHTML =
694
+ '<td colspan="7" class="empty-state">' + escapeHtml(i18n.t("projects.emptyRow")) + "</td>";
695
+ tb.appendChild(tr);
696
+ } else {
697
+ list.forEach((p) => tb.appendChild(row(p, lastDefault)));
698
+ }
699
+ }
700
+
701
+ function row(p, defaultName) {
702
+ const tr = document.createElement("tr");
703
+ const isDefault = p.name === defaultName;
704
+ const badges =
705
+ (isDefault ? '<span class="badge badge-default">' + escapeHtml(i18n.t("projects.badgeDefault")) + "</span>" : "") +
706
+ (p.enabled
707
+ ? '<span class="badge badge-on">' + escapeHtml(i18n.t("projects.badgeOn")) + "</span>"
708
+ : '<span class="badge badge-off">' + escapeHtml(i18n.t("projects.badgeOff")) + "</span>");
709
+ const secretBadge = p.webhook_secret_set
710
+ ? '<span class="badge badge-on" title="' + escapeAttr(i18n.t("projects.secretSetTitle")) + '">' + escapeHtml(i18n.t("projects.secretBadge")) + "</span>"
711
+ : "";
712
+ tr.innerHTML = `
713
+ <td><code>${escapeHtml(p.name)}</code></td>
714
+ <td>${badges}</td>
715
+ <td class="path-cell">${escapeHtml(String(p.root_path))}</td>
716
+ <td class="path-cell">${escapeHtml(String(p.worktree_base_dir))}</td>
717
+ <td>${escapeHtml(p.default_model)}</td>
718
+ <td>
719
+ <div title="${escapeAttr(i18n.t("projects.maskedTokenTitle"))}">${escapeHtml(i18n.tv(p.bot_token_masked || ""))}</div>
720
+ ${secretBadge}
721
+ </td>
722
+ <td>
723
+ <div class="btn-row">
724
+ <button type="button" class="btn-primary" data-a="default" data-n="${escapeAttr(p.name)}" ${isDefault ? "disabled" : ""}>${escapeHtml(i18n.t("projects.btnMakeDefault"))}</button>
725
+ <button type="button" data-a="edit" data-n="${escapeAttr(p.name)}">${escapeHtml(i18n.t("common.edit"))}</button>
726
+ <button type="button" class="btn-danger" data-a="del" data-n="${escapeAttr(p.name)}">${escapeHtml(i18n.t("common.delete"))}</button>
727
+ </div>
728
+ </td>`;
729
+ return tr;
730
+ }
731
+
732
+ async function loadAll() {
733
+ setGlobalMsg("", "");
734
+ setBusy(true);
735
+ try {
736
+ const result = await adminSummary.loadSummaryGrid((msg) => setGlobalMsg("err", msg));
737
+ if (!result.ok) return;
738
+ const d = result.projectsPayload;
739
+ lastProjects = d.projects || [];
740
+ lastDefault = d.default_project || "";
741
+ renderProjectTable(d);
742
+ } finally {
743
+ setBusy(false);
744
+ updateFormModeBanner();
745
+ }
746
+ }
747
+
748
+ $("#proj-table").addEventListener("click", async (e) => {
749
+ const b = e.target.closest("button[data-a]");
750
+ if (!b || busy) return;
751
+ const a = b.dataset.a;
752
+ const n = b.dataset.n;
753
+ if (a === "default") {
754
+ setGlobalMsg("", "");
755
+ setBusy(true);
756
+ try {
757
+ const r = await fetch("/api/projects/default", {
758
+ method: "POST",
759
+ headers: { "Content-Type": "application/json" },
760
+ body: JSON.stringify({ name: n }),
761
+ });
762
+ const t = await r.text();
763
+ if (!r.ok) {
764
+ setGlobalMsg("err", await parseApiError(r, t));
765
+ return;
766
+ }
767
+ setGlobalMsg("ok", i18n.t("projects.setDefaultOk", { name: n }));
768
+ await loadAll();
769
+ } finally {
770
+ setBusy(false);
771
+ }
772
+ }
773
+ if (a === "del") {
774
+ if (!confirm(i18n.t("projects.confirmDelete", { name: n }))) return;
775
+ setBusy(true);
776
+ try {
777
+ const r = await fetch("/api/projects/" + encodeURIComponent(n), { method: "DELETE" });
778
+ const t = await r.text();
779
+ if (!r.ok) {
780
+ setGlobalMsg("err", await parseApiError(r, t));
781
+ return;
782
+ }
783
+ setGlobalMsg("ok", i18n.t("projects.deletedOk"));
784
+ await loadAll();
785
+ } finally {
786
+ setBusy(false);
787
+ }
788
+ }
789
+ if (a === "edit") {
790
+ const p = lastProjects.find((x) => x.name === n);
791
+ if (!p) return;
792
+ const f = $("#proj-form");
793
+ f.name.value = p.name;
794
+ f.root_path.value = p.root_path;
795
+ f.worktree_base_dir.value = p.worktree_base_dir;
796
+ f.default_model.value = p.default_model;
797
+ f.enabled.checked = !!p.enabled;
798
+ $("#f-bot-token").value = "";
799
+ $("#f-bot-token").removeAttribute("required");
800
+ $("#f-wh-secret").value = "";
801
+ $("#f-chats").value = (p.allowed_chat_ids || []).join(", ");
802
+ $("#f-users").value = (p.allowed_user_ids || []).join(", ");
803
+ f.name.readOnly = true;
804
+ $("#btn-submit").textContent = i18n.t("projects.btnSave");
805
+ setGlobalMsg("info", i18n.t("projects.editInfo"));
806
+ updateFormModeBanner();
807
+ f.name.focus();
808
+ }
809
+ });
810
+
811
+ $("#btn-clear").addEventListener("click", () => {
812
+ $("#proj-form").reset();
813
+ $("#proj-form").name.readOnly = false;
814
+ $("#f-bot-token").setAttribute("required", "");
815
+ $("#btn-submit").textContent = i18n.t("projects.btnAdd");
816
+ setGlobalMsg("", "");
817
+ updateFormModeBanner();
818
+ });
819
+
820
+ $("#proj-form").addEventListener("submit", async (e) => {
821
+ e.preventDefault();
822
+ if (busy) return;
823
+ setGlobalMsg("", "");
824
+ const f = e.target;
825
+ const editing = f.name.readOnly;
826
+ const allowed_chat_ids = parseIdList($("#f-chats").value);
827
+ const allowed_user_ids = parseIdList($("#f-users").value);
828
+ if (!allowed_chat_ids.length) {
829
+ setGlobalMsg("err", i18n.t("projects.errChatIds"));
830
+ return;
831
+ }
832
+ const botTok = $("#f-bot-token").value.trim();
833
+ if (!editing && !botTok) {
834
+ setGlobalMsg("err", i18n.t("projects.errBotToken"));
835
+ return;
836
+ }
837
+ const body = {
838
+ name: f.name.value.trim(),
839
+ root_path: f.root_path.value.trim(),
840
+ worktree_base_dir: f.worktree_base_dir.value.trim(),
841
+ default_model: f.default_model.value,
842
+ enabled: f.enabled.checked,
843
+ allowed_chat_ids,
844
+ allowed_user_ids,
845
+ };
846
+ if (botTok) body.bot_token = botTok;
847
+ const ws = $("#f-wh-secret").value.trim();
848
+ if (ws) body.webhook_secret = ws;
849
+ else if (!editing) body.webhook_secret = null;
850
+ const url = editing ? "/api/projects/" + encodeURIComponent(body.name) : "/api/projects";
851
+ const method = editing ? "PUT" : "POST";
852
+ setBusy(true);
853
+ try {
854
+ const r = await fetch(url, {
855
+ method,
856
+ headers: { "Content-Type": "application/json" },
857
+ body: JSON.stringify(body),
858
+ });
859
+ const t = await r.text();
860
+ if (!r.ok) {
861
+ setGlobalMsg("err", await parseApiError(r, t));
862
+ return;
863
+ }
864
+ f.name.readOnly = false;
865
+ $("#btn-submit").textContent = i18n.t("projects.btnAdd");
866
+ $("#f-bot-token").setAttribute("required", "");
867
+ f.reset();
868
+ setGlobalMsg("ok", editing ? i18n.t("projects.updatedOk") : i18n.t("projects.createdOk"));
869
+ await loadAll();
870
+ } finally {
871
+ setBusy(false);
872
+ }
873
+ });
874
+
875
+ loadAll();
876
+ </script>
877
+ </body>
878
+ </html>