team-chat 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +458 -2
  3. package/agents/rules.md +52 -0
  4. package/agents/runtimes/codex.md +14 -0
  5. package/bin/bridge +3 -0
  6. package/bin/runner +3 -0
  7. package/cli/lib/agents.js +97 -0
  8. package/cli/lib/docs.js +151 -0
  9. package/cli/lib/local.js +41 -0
  10. package/cli/lib/server.js +52 -0
  11. package/cli/lib/util.js +107 -0
  12. package/cli/team-chat.js +383 -0
  13. package/host/bridge/attempts.js +43 -0
  14. package/host/bridge/bridge.js +326 -0
  15. package/host/bridge/config.js +105 -0
  16. package/host/bridge/knowledge-outbox.js +96 -0
  17. package/host/bridge/launcher.js +126 -0
  18. package/host/bridge/main.js +87 -0
  19. package/host/bridge/progress.js +74 -0
  20. package/host/bridge/prompt.js +97 -0
  21. package/host/bridge/run-archive.js +200 -0
  22. package/host/bridge/thread.js +44 -0
  23. package/host/bridge/triage.js +97 -0
  24. package/host/bridge/workdir.js +41 -0
  25. package/host/knowledge/card.js +43 -0
  26. package/host/knowledge/errors.js +50 -0
  27. package/host/knowledge/lesson.js +153 -0
  28. package/host/knowledge/process.js +160 -0
  29. package/host/knowledge/scrub.js +29 -0
  30. package/host/knowledge/shell.js +74 -0
  31. package/host/knowledge/stack.js +229 -0
  32. package/host/knowledge/transcript.js +82 -0
  33. package/host/lib/chat-api.js +120 -0
  34. package/host/lib/log.js +6 -0
  35. package/host/lib/pid-lock.js +65 -0
  36. package/host/lib/shell-words.js +45 -0
  37. package/host/runner/bridge-process.js +82 -0
  38. package/host/runner/config.js +81 -0
  39. package/host/runner/main.js +69 -0
  40. package/host/runner/retention.js +40 -0
  41. package/host/runner/runner.js +161 -0
  42. package/package.json +67 -5
  43. package/server/dist/src/app.js +25 -0
  44. package/server/dist/src/app.module.js +50 -0
  45. package/server/dist/src/attachments/attachment-store.js +111 -0
  46. package/server/dist/src/attachments/attachments.controller.js +67 -0
  47. package/server/dist/src/attachments/mime.js +84 -0
  48. package/server/dist/src/attachments/uploads.interceptor.js +72 -0
  49. package/server/dist/src/auth/participant.guard.js +53 -0
  50. package/server/dist/src/bridges/bridge-restart.service.js +80 -0
  51. package/server/dist/src/bridges/bridges.controller.js +47 -0
  52. package/server/dist/src/chats/chats.controller.js +83 -0
  53. package/server/dist/src/chats/chats.service.js +128 -0
  54. package/server/dist/src/collector/collector.controller.js +71 -0
  55. package/server/dist/src/collector/collector.module.js +23 -0
  56. package/server/dist/src/collector/collector.service.js +111 -0
  57. package/server/dist/src/common/clock.js +20 -0
  58. package/server/dist/src/common/errors.js +37 -0
  59. package/server/dist/src/common/format.js +14 -0
  60. package/server/dist/src/common/http.js +17 -0
  61. package/server/dist/src/common/slug.js +28 -0
  62. package/server/dist/src/common/tokens.js +19 -0
  63. package/server/dist/src/common/validation.js +41 -0
  64. package/server/dist/src/config/config.js +39 -0
  65. package/server/dist/src/conversation.module.js +41 -0
  66. package/server/dist/src/db/database.js +73 -0
  67. package/server/dist/src/db/database.module.js +51 -0
  68. package/server/dist/src/db/schema.js +212 -0
  69. package/server/dist/src/decisions/decisions.controller.js +55 -0
  70. package/server/dist/src/decisions/decisions.service.js +109 -0
  71. package/server/dist/src/documents/documents.controller.js +87 -0
  72. package/server/dist/src/documents/documents.service.js +189 -0
  73. package/server/dist/src/health/health.controller.js +29 -0
  74. package/server/dist/src/knowledge/knowledge.controller.js +83 -0
  75. package/server/dist/src/knowledge/knowledge.module.js +25 -0
  76. package/server/dist/src/knowledge/knowledge.service.js +189 -0
  77. package/server/dist/src/local/docs-sync.service.js +97 -0
  78. package/server/dist/src/local/local.controller.js +197 -0
  79. package/server/dist/src/local/local.guard.js +55 -0
  80. package/server/dist/src/local/local.module.js +30 -0
  81. package/server/dist/src/local/setup.service.js +154 -0
  82. package/server/dist/src/local/transfer.service.js +151 -0
  83. package/server/dist/src/main.js +33 -0
  84. package/server/dist/src/mcp/chat-tools.js +282 -0
  85. package/server/dist/src/mcp/mcp.controller.js +58 -0
  86. package/server/dist/src/mcp/mcp.module.js +24 -0
  87. package/server/dist/src/mentions/mention-state.js +80 -0
  88. package/server/dist/src/mentions/mentions.controller.js +100 -0
  89. package/server/dist/src/mentions/mentions.service.js +203 -0
  90. package/server/dist/src/messages/message.presenter.js +102 -0
  91. package/server/dist/src/messages/messages.service.js +149 -0
  92. package/server/dist/src/messages/thread.controller.js +179 -0
  93. package/server/dist/src/participants/directory.service.js +68 -0
  94. package/server/dist/src/participants/participants.module.js +24 -0
  95. package/server/dist/src/participants/participants.service.js +84 -0
  96. package/server/dist/src/settings/settings.service.js +42 -0
  97. package/server/dist/src/sharing/consent.js +16 -0
  98. package/server/dist/src/sharing/sharing.module.js +22 -0
  99. package/server/dist/src/sharing/sharing.service.js +175 -0
  100. package/server/dist/src/team/runner.service.js +75 -0
  101. package/server/dist/src/team/team.controller.js +178 -0
  102. package/server/dist/src/team/team.module.js +25 -0
  103. package/server/dist/src/team/team.service.js +280 -0
  104. package/server/dist/src/web/template.js +57 -0
  105. package/server/dist/src/web/web-login.service.js +74 -0
  106. package/server/dist/src/web/web.controller.js +203 -0
  107. package/server/dist/src/web/web.module.js +25 -0
  108. package/server/drizzle/0000_init.sql +169 -0
  109. package/server/drizzle/0001_seed_roles.sql +25 -0
  110. package/server/drizzle/0002_knowledge.sql +17 -0
  111. package/server/drizzle/0003_sharing.sql +30 -0
  112. package/server/drizzle/meta/0000_snapshot.json +1224 -0
  113. package/server/drizzle/meta/0001_snapshot.json +1224 -0
  114. package/server/drizzle/meta/0002_snapshot.json +1359 -0
  115. package/server/drizzle/meta/0003_snapshot.json +1545 -0
  116. package/server/drizzle/meta/_journal.json +34 -0
  117. package/server/package.json +1 -0
  118. package/server/views/chat.html +865 -0
  119. package/server/views/documents.html +204 -0
  120. package/server/views/login.html +31 -0
  121. package/server/views/setup.html +60 -0
  122. package/server/views/team.html +369 -0
@@ -0,0 +1,865 @@
1
+ <!doctype html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{ chat.name }} — командный чат</title>
7
+ <style>
8
+ :root{
9
+ --bg:#f6f7f9; --card:#fff; --line:#e4e7ec; --ink:#14171c; --muted:#6b7280;
10
+ --brand:#0057c2; --accent:#b45309; --ok:#11a36b;
11
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif;
12
+ }
13
+ *{box-sizing:border-box}
14
+ body{margin:0;background:var(--bg);color:var(--ink);font-size:15px;line-height:1.5}
15
+ header{background:var(--card);border-bottom:1px solid var(--line);padding:12px 20px;
16
+ display:flex;align-items:center;gap:12px;position:sticky;top:0;z-index:5}
17
+ header h1{font-size:15px;margin:0;font-weight:600}
18
+ .who{margin-left:auto;color:var(--muted);font-size:13px}
19
+ /* Мосты: кто из агентов на связи, и кнопка на случай, когда нет. */
20
+ #bridges{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--muted)}
21
+ #bridges .br{display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
22
+ #bridges .br .pip{width:8px;height:8px;border-radius:50%;flex:none}
23
+ #bridges .br.off{color:var(--accent)}
24
+ #bridges button{background:#fff;color:var(--muted);border:1px solid var(--line);
25
+ padding:2px 9px;font-size:12px;font-weight:400}
26
+ #bridges button:hover:not(:disabled){border-color:var(--brand);color:var(--brand)}
27
+ #bridges button.hot{border-color:var(--accent);color:var(--accent)}
28
+ #bridges .stuck{color:var(--accent)}
29
+ .layout{display:grid;grid-template-columns:200px minmax(0,1fr) 340px;gap:16px;
30
+ max-width:1200px;margin:0 auto;padding:16px 20px 0}
31
+ @media (max-width:1100px){.layout{grid-template-columns:minmax(0,1fr) 300px}
32
+ #chats{grid-column:1 / -1}}
33
+ @media (max-width:900px){.layout{grid-template-columns:1fr}}
34
+ /* Чаты-проекты. На узком экране — строкой над тредом. */
35
+ #chats{align-self:start;position:sticky;top:64px}
36
+ #chatlist{padding:6px}
37
+ @media (max-width:1100px){#chats{position:static}#chatlist{display:flex;flex-wrap:wrap;gap:4px}}
38
+ .chat{display:flex;align-items:center;gap:6px;padding:7px 10px;border-radius:8px;
39
+ color:var(--ink);text-decoration:none;font-size:14px}
40
+ .chat:hover{background:#f3f4f6}
41
+ .chat.on{background:#e8f0fb;color:var(--brand);font-weight:600}
42
+ .chat .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
43
+ .chat .unread{width:7px;height:7px;border-radius:50%;background:var(--brand);flex:none}
44
+ .chat .badge{font-size:11px;border-radius:99px;padding:0 6px;flex:none;font-weight:600}
45
+ .chat .badge.owner{background:#fef3c7;color:#92400e}
46
+ .chat .badge.agents{background:#e8f0fb;color:var(--brand)}
47
+ #chats .tools2{display:flex;gap:6px;padding:6px 8px 10px}
48
+ #chats .tools2 button{background:#fff;color:var(--muted);border:1px solid var(--line);
49
+ padding:3px 9px;font-size:12px;font-weight:400}
50
+ #chats .tools2 button:hover{border-color:var(--brand);color:var(--brand)}
51
+ dialog{border:1px solid var(--line);border-radius:12px;padding:0;width:min(520px,calc(100vw - 32px))}
52
+ dialog::backdrop{background:rgba(20,23,28,.35)}
53
+ dialog form{display:flex;flex-direction:column;gap:10px;padding:18px;border:0}
54
+ dialog h3{margin:0 0 4px;font-size:16px}
55
+ dialog label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--muted)}
56
+ dialog input,dialog textarea{font:inherit;color:var(--ink);border:1px solid var(--line);
57
+ border-radius:8px;padding:8px 10px;outline:none}
58
+ dialog input:focus,dialog textarea:focus{border-color:var(--brand)}
59
+ dialog input:disabled{background:#f6f7f9;color:var(--muted)}
60
+ dialog textarea{min-height:110px;resize:vertical}
61
+ dialog .row{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}
62
+ dialog .row button{padding:8px 16px}
63
+ dialog .row .ghost{background:#fff;color:var(--muted);border:1px solid var(--line)}
64
+ dialog .err{color:#b91c1c;font-size:13px;min-height:1em}
65
+ dialog select{font:inherit;color:var(--ink);border:1px solid var(--line);border-radius:8px;
66
+ padding:6px 8px;background:#fff}
67
+ #chatroles{display:flex;flex-direction:column;gap:6px}
68
+ #chatroles:empty{display:none}
69
+ #chatroles .cap{font-size:13px;color:var(--muted)}
70
+ #chatroles .ar{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr);gap:8px;align-items:center;font-size:14px}
71
+ .panel{background:var(--card);border:1px solid var(--line);border-radius:12px}
72
+ .panel h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);
73
+ margin:0;padding:14px 16px;border-bottom:1px solid var(--line)}
74
+ #thread{padding:8px 0;min-height:50vh;max-height:calc(100vh - 230px);overflow-y:auto}
75
+ .msg{padding:10px 16px;display:flex;gap:10px;position:relative}
76
+ .msg:hover{background:#fafbfc}
77
+ .dot{width:30px;height:30px;border-radius:50%;flex:none;color:#fff;font-size:12px;
78
+ font-weight:600;display:grid;place-items:center}
79
+ .meta{font-size:12px;color:var(--muted);margin-bottom:2px}
80
+ .meta b{color:var(--ink);font-weight:600}
81
+ .role{border:1px solid var(--line);border-radius:99px;padding:0 6px;margin-left:4px}
82
+ .body{white-space:pre-wrap;word-wrap:break-word}
83
+ .body:empty{display:none}
84
+ /* Вложения: картинки — превью, остальное — строкой со значком и размером. */
85
+ .atts{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px}
86
+ .atts img{max-width:240px;max-height:180px;border-radius:8px;border:1px solid var(--line);
87
+ cursor:zoom-in;background:#f3f4f6;min-width:60px;min-height:40px}
88
+ .atts .file{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);
89
+ border-radius:8px;padding:5px 10px;font-size:13px;color:var(--ink);text-decoration:none;max-width:320px}
90
+ .atts .file:hover{border-color:var(--brand);color:var(--brand)}
91
+ .atts .file span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
92
+ .atts .file small{color:var(--muted);flex:none}
93
+ #chips{display:none;flex-wrap:wrap;gap:6px;padding:8px 12px 0}
94
+ #chips.on{display:flex}
95
+ #chips .chip{display:inline-flex;align-items:center;gap:6px;background:#f3f4f6;border-radius:99px;
96
+ padding:2px 4px 2px 10px;font-size:12px;max-width:260px}
97
+ #chips .chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
98
+ #chips .chip button{background:none;color:var(--muted);padding:0 6px;font-weight:400}
99
+ #attach{background:#fff;color:var(--muted);border:1px solid var(--line);padding:0 12px;font-size:18px}
100
+ #attach:hover{border-color:var(--brand);color:var(--brand)}
101
+ .panel.drop{outline:2px dashed var(--brand);outline-offset:-6px}
102
+ .pin{position:absolute;right:10px;top:8px;opacity:0;border:1px solid var(--line);
103
+ background:#fff;border-radius:6px;font-size:11px;padding:3px 8px;cursor:pointer;color:var(--muted)}
104
+ .msg:hover .pin{opacity:1}
105
+ .pin:hover{border-color:var(--brand);color:var(--brand)}
106
+ .tools{position:absolute;right:10px;top:8px;display:flex;gap:6px}
107
+ .tools .pin{position:static}
108
+ .ref{color:var(--muted);text-decoration:none;margin-left:6px}
109
+ .ref:hover{color:var(--brand)}
110
+ .tag.ask{color:var(--accent);border-color:var(--accent)}
111
+ .msg.flash{background:#fff7e6;transition:background 1.5s}
112
+ .tag{display:inline-block;font-size:11px;color:var(--ok);border:1px solid var(--ok);
113
+ border-radius:99px;padding:0 6px;margin-top:4px}
114
+ /* Индикатор активности. Живёт между лентой и формой: там его ищет глаз,
115
+ привыкший, что «собеседник печатает» стоит под последним сообщением. */
116
+ #activity{padding:0 16px;border-top:1px solid var(--line)}
117
+ #activity:empty{display:none}
118
+ #activity .act{display:flex;align-items:center;gap:8px;padding:8px 0;font-size:13px;color:var(--muted)}
119
+ #activity .act + .act{border-top:1px dashed var(--line)}
120
+ /* Список открытых обращений: кто должен ответить и что мешает. */
121
+ #open{padding:4px 0}
122
+ #open h3{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);
123
+ margin:8px 16px 2px;font-weight:600}
124
+ .item{padding:8px 16px;border-bottom:1px dashed var(--line);font-size:13px}
125
+ .item:last-child{border-bottom:0}
126
+ .item .top{display:flex;align-items:center;gap:6px}
127
+ .item .top .pip{width:8px;height:8px;border-radius:50%;flex:none}
128
+ .item .top b{font-weight:600}
129
+ .item .age{margin-left:auto;color:var(--muted);font-size:12px;white-space:nowrap}
130
+ .item .q{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:2px 0}
131
+ .item .st{font-weight:500}
132
+ .item .why{color:var(--muted);font-size:12px}
133
+ .item .links{margin-top:3px;display:flex;gap:6px}
134
+ .item .links a,.item .links button{color:var(--muted);text-decoration:none;font-size:12px;background:#fff;
135
+ border:1px solid var(--line);border-radius:6px;padding:1px 7px;font-weight:400;cursor:pointer}
136
+ .item .links a:hover,.item .links button:hover{border-color:var(--brand);color:var(--brand)}
137
+ .item.working .st{color:var(--brand)}
138
+ .item.answered .st{color:var(--ok)}
139
+ .item.attn .st{color:var(--accent)}
140
+ .item.owner{background:#fffbeb}
141
+ .stale{background:#fef3c7;color:#92400e;font-size:12px;padding:6px 16px}
142
+ #replybar{display:none;align-items:center;gap:8px;padding:6px 12px 0;font-size:12px;color:var(--muted)}
143
+ #replybar.on{display:flex}
144
+ #replybar span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
145
+ #replybar button{background:none;color:var(--muted);padding:0 4px;font-weight:400}
146
+ aside{display:flex;flex-direction:column;gap:16px}
147
+ #activity .pip{width:8px;height:8px;border-radius:50%;flex:none}
148
+ #activity .who{color:var(--ink);font-weight:600}
149
+ #activity .goto{margin-left:auto;color:var(--muted);text-decoration:none;font-size:12px;
150
+ border:1px solid var(--line);border-radius:6px;padding:1px 7px}
151
+ #activity .goto:hover{border-color:var(--brand);color:var(--brand)}
152
+ /* Потеря связи — то, что требует человека. */
153
+ #activity .offline .what{color:var(--accent)}
154
+ #activity .working .pip{animation:pulse 1.1s ease-in-out infinite}
155
+ @keyframes pulse{0%,100%{opacity:.25;transform:scale(.8)}50%{opacity:1;transform:scale(1.15)}}
156
+ /* Многоточие набирается по точке — «работает…» должно ВЫГЛЯДЕТЬ длящимся,
157
+ иначе статичная надпись читается так же, как зависшая страница. */
158
+ .dots::after{content:'';animation:dots 1.6s steps(4,end) infinite}
159
+ @keyframes dots{0%{content:''}25%{content:'.'}50%{content:'..'}75%{content:'...'}}
160
+ @media (prefers-reduced-motion:reduce){
161
+ #activity .working .pip{animation:none;opacity:1}
162
+ .dots::after{content:'…';animation:none}
163
+ }
164
+ form{display:flex;gap:8px;padding:12px;border-top:1px solid var(--line)}
165
+ textarea{flex:1;resize:vertical;min-height:46px;max-height:200px;padding:10px 12px;
166
+ border:1px solid var(--line);border-radius:8px;font:inherit;outline:none}
167
+ textarea:focus{border-color:var(--brand)}
168
+ button{background:var(--brand);color:#fff;border:0;border-radius:8px;padding:0 18px;
169
+ font:inherit;font-weight:600;cursor:pointer}
170
+ button:disabled{opacity:.5;cursor:default}
171
+ #decisions{padding:6px 0;max-height:40vh;overflow-y:auto}
172
+ #open{max-height:calc(60vh - 120px);overflow-y:auto}
173
+ .dec{padding:10px 16px;border-bottom:1px solid var(--line)}
174
+ .dec:last-child{border-bottom:0}
175
+ .dec .n{font-size:11px;color:var(--accent);font-weight:700}
176
+ .dec .t{font-weight:600;margin:2px 0}
177
+ .dec .s{font-size:12px;color:var(--muted)}
178
+ .dec.old .t{text-decoration:line-through;color:var(--muted)}
179
+ .empty{padding:16px;color:var(--muted);font-size:13px}
180
+ .warn{background:#fef3c7;border:1px solid #fcd34d;border-radius:10px;
181
+ padding:12px 16px;margin:16px auto;max-width:1160px}
182
+ </style>
183
+ </head>
184
+ <body>
185
+ <header>
186
+ <h1>{{ chat.name }}</h1>
187
+ <a href="/team?from={{ chat.slug }}" style="color:#6b7280;text-decoration:none;font-size:13px">команда →</a>
188
+ <a href="/c/{{ chat.slug }}/documents" style="color:#6b7280;text-decoration:none;font-size:13px">документы →</a>
189
+ <span id="bridges"></span>
190
+ <span class="who">{{ me.name }} · <a href="/logout" style="color:inherit" title="выйти в этом браузере">выйти</a></span>
191
+ </header>
192
+
193
+ <div class="layout">
194
+ <nav class="panel" id="chats">
195
+ <h2>Чаты</h2>
196
+ <div id="chatlist"></div>
197
+ {{#owner}}
198
+ <div class="tools2">
199
+ <button type="button" id="newchat">+ новый чат</button>
200
+ <button type="button" id="editchat" title="название, каталог проекта, описание для агентов">настройки</button>
201
+ </div>
202
+ {{/owner}}
203
+ </nav>
204
+
205
+ <section class="panel">
206
+ <h2>Тред</h2>
207
+ <div id="thread"><div class="empty">Загрузка…</div></div>
208
+ <div id="activity"></div>
209
+ <div id="replybar"><span></span><button type="button" id="replycancel" title="не отвечать">✕</button></div>
210
+ <div id="chips"></div>
211
+ <form id="send">
212
+ <input type="file" id="files" multiple hidden>
213
+ <button type="button" id="attach" title="Приложить файлы — можно перетащить в чат или вставить скриншот (Ctrl+V)">📎</button>
214
+ <textarea id="body" placeholder="Написать в тред… (Enter — отправить, Shift+Enter — новая строка)"></textarea>
215
+ <button type="submit">Отправить</button>
216
+ </form>
217
+ </section>
218
+
219
+ <aside>
220
+ <section class="panel">
221
+ <h2>Открытые обращения <span id="opencount"></span></h2>
222
+ <div id="open"><div class="empty">Загрузка…</div></div>
223
+ </section>
224
+ <section class="panel">
225
+ <h2>Журнал решений</h2>
226
+ <div id="decisions"><div class="empty">Пока ничего не решено.</div></div>
227
+ </section>
228
+ </aside>
229
+ </div>
230
+
231
+ {{#owner}}
232
+ <dialog id="chatdlg">
233
+ <form method="dialog" id="chatform">
234
+ <h3 id="chatdlgtitle">Новый чат</h3>
235
+ <label>Название
236
+ <input name="name" required maxlength="100" placeholder="Kettu">
237
+ </label>
238
+ <label>Адрес (латиница, цифры, дефис; пусто — из названия)
239
+ <input name="slug" maxlength="40" pattern="[a-z0-9][a-z0-9\-]{0,39}" placeholder="kettu">
240
+ </label>
241
+ <label>Каталог проекта на машине с мостами — агент будет работать в нём
242
+ <input name="workdir" maxlength="500" placeholder="/home/…/projects/kettu">
243
+ </label>
244
+ <label>О проекте — что это, какие репозитории, где главное. Уходит агенту в каждое задание
245
+ <textarea name="description" maxlength="5000"></textarea>
246
+ </label>
247
+ <div id="chatroles"></div>
248
+ <div class="err" id="chaterr"></div>
249
+ <div class="row">
250
+ <button type="button" class="ghost" id="chatcancel">отмена</button>
251
+ <button type="submit" id="chatsave">Создать</button>
252
+ </div>
253
+ </form>
254
+ </dialog>
255
+ {{/owner}}
256
+
257
+ <script>
258
+ const TOKEN = {{json token}};
259
+ const CHAT = {{json chat}};
260
+ // Всё, что живёт внутри чата, — по его адресу. Вложения и мосты общие.
261
+ const API = '/api/chats/' + encodeURIComponent(CHAT.slug);
262
+ const IS_OWNER = {{json owner}};
263
+ const H = {'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json','Accept':'application/json'};
264
+ const thread = document.getElementById('thread');
265
+ const decisions = document.getElementById('decisions');
266
+ const activity = document.getElementById('activity');
267
+ const openList = document.getElementById('open');
268
+ const openCount = document.getElementById('opencount');
269
+ let lastId = 0, seen = new Set();
270
+ // Последний ответ сервера про открытое: обращения к агентам и вопросы владельцу.
271
+ // Держим его, чтобы перерисовывать возраст каждую секунду, не дёргая сеть.
272
+ let activityState = [], ownerWait = [];
273
+ // Мосты: когда каждый агент последний раз выходил на связь и не висит ли запрос
274
+ // на перезапуск. null — сервер ещё не ответил.
275
+ let bridgesState = null;
276
+ // На какое сообщение отвечаем. Без привязки ответ владельца не снимает
277
+ // ожидание: «связанный ответ» — это ровно reply_to_id вопроса.
278
+ let replyTo = null;
279
+
280
+ const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
281
+ const time = iso => new Date(iso).toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
282
+
283
+ function buildMessage(m){
284
+ seen.add(m.id);
285
+ const el = document.createElement('div');
286
+ el.className = 'msg';
287
+ el.id = 'msg-' + m.id;
288
+ el.dataset.author = m.author.name;
289
+ el.dataset.handle = m.author.handle;
290
+ el.dataset.kind = m.author.kind;
291
+ el.dataset.excerpt = m.body.slice(0, 120);
292
+ el.innerHTML =
293
+ `<div class="dot" style="background:${esc(m.author.accent)}">${esc(m.author.name.slice(0,1).toUpperCase())}</div>
294
+ <div style="min-width:0;flex:1">
295
+ <div class="meta"><b>${esc(m.author.name)}</b><span class="role">${esc(m.author.role_name || m.author.role)}</span> · ${time(m.created_at)} · #${m.id}${m.reply_to_id ? `<a class="ref" href="#msg-${m.reply_to_id}" data-goto="${m.reply_to_id}">↩ #${m.reply_to_id}</a>` : ''}</div>
296
+ <div class="body">${esc(m.body)}</div>
297
+ ${attachmentsHtml(m.attachments || [])}
298
+ ${m.asks_owner ? `<span class="tag ask">вопрос владельцу</span>` : ''}
299
+ ${m.decision_number ? `<span class="tag">решение №${m.decision_number}</span>` : ''}
300
+ </div>
301
+ <div class="tools">
302
+ <button class="pin reply" data-id="${m.id}">ответить</button>
303
+ ${IS_OWNER && !m.decision_number ? `<button class="pin fix" data-id="${m.id}">зафиксировать</button>` : ''}
304
+ </div>`;
305
+ return el;
306
+ }
307
+
308
+ const fmtSize = n => n < 1024 ? n + ' Б' : n < 1048576 ? Math.round(n / 1024) + ' КБ' : (n / 1048576).toFixed(1) + ' МБ';
309
+
310
+ function attachmentsHtml(list){
311
+ if (!list.length) return '';
312
+ return `<div class="atts">${list.map(a => a.image
313
+ ? `<img data-att="${a.id}" alt="${esc(a.name)}" title="${esc(a.name)} · ${fmtSize(a.size)}">`
314
+ : `<a class="file" href="#" data-dl="${a.id}" data-name="${esc(a.name)}" title="скачать">📄 <span>${esc(a.name)}</span><small>${fmtSize(a.size)}</small></a>`
315
+ ).join('')}</div>`;
316
+ }
317
+
318
+ // Файлы отдаются только с токеном, а <img src> заголовок не пошлёт. Поэтому
319
+ // качаем сами и подставляем blob: — токен в адресе осел бы в логах и истории.
320
+ const blobs = new Map();
321
+ async function attachmentBlob(id){
322
+ if (!blobs.has(id)) blobs.set(id, fetch('/api/attachments/' + id, {headers:{'Authorization':'Bearer '+TOKEN}})
323
+ .then(r => { if (!r.ok) throw new Error(r.status); return r.blob(); })
324
+ .then(b => URL.createObjectURL(b))
325
+ .catch(e => { blobs.delete(id); throw e; }));
326
+ return blobs.get(id);
327
+ }
328
+ function loadImages(root){
329
+ root.querySelectorAll('img[data-att]:not([src])').forEach(img =>
330
+ attachmentBlob(img.dataset.att).then(u => img.src = u).catch(() => img.alt = '⚠ ' + img.alt));
331
+ }
332
+
333
+ function renderMessage(m){
334
+ if (seen.has(m.id)) return;
335
+ const el = buildMessage(m);
336
+ thread.appendChild(el);
337
+ loadImages(el);
338
+ lastId = Math.max(lastId, m.id);
339
+ }
340
+
341
+ /**
342
+ * Довести ленту до сообщения id. Страница грузит окно последних 50, а вопрос,
343
+ * о котором напоминает список, после перерыва легко оказывается старше —
344
+ * и ссылка «к вопросу» вела бы в никуда.
345
+ */
346
+ async function ensureLoaded(id){
347
+ if (document.getElementById('msg-' + id) || !seen.size) return;
348
+ const first = Math.min(...seen);
349
+ if (id >= first) return;
350
+ const older = [];
351
+ let after = id - 1;
352
+ while (after < first - 1){
353
+ const r = await fetch(`${API}/messages?after_id=${after}&limit=500`, {headers:H});
354
+ if (!r.ok) return;
355
+ const {messages} = await r.json();
356
+ if (!messages.length) break;
357
+ older.push(...messages.filter(m => m.id < first && !seen.has(m.id)));
358
+ after = messages[messages.length - 1].id;
359
+ }
360
+ const anchor = thread.querySelector('.msg');
361
+ older.forEach(m => { const el = buildMessage(m); thread.insertBefore(el, anchor); loadImages(el); });
362
+ }
363
+
364
+ async function goTo(id){
365
+ await ensureLoaded(id);
366
+ const el = document.getElementById('msg-' + id);
367
+ if (!el) return;
368
+ el.scrollIntoView({block:'center', behavior:'smooth'});
369
+ el.classList.add('flash');
370
+ setTimeout(() => el.classList.remove('flash'), 1600);
371
+ }
372
+
373
+ // Опрос ленты — раз в 2 с. При 3 с замеры давали до 4,8 с от изменения на
374
+ // сервере до экрана: в ориентир «не позднее 5 с» формально, но без запаса на
375
+ // медленный запрос. Без свежего ответа сервера 15 с (семь пропущенных опросов)
376
+ // страница перестаёт утверждать «работает»: серверный срок годности сигнала
377
+ // здесь не поможет — до страницы не доходит и он.
378
+ const POLL_MS = 2000, OFFLINE_MS = 15000;
379
+ // Мост опрашивает чат раз в 10 с. Минута тишины от адресата — факт, который
380
+ // стоит показать у «ожидает запуска»: иначе остановленный мост не отличить от очереди.
381
+ const SEEN_STALE_MS = 60000;
382
+ // Запрос на перезапуск хост забирает за секунды. Висит дольше — забирать некому.
383
+ const RESTART_STUCK_MS = 30000;
384
+ let lastSync = 0; // когда сервер последний раз ответил (часы браузера)
385
+ let skew = 0; // часы сервера минус часы браузера: «since» — по серверу
386
+
387
+ /** «2 мин», «40 с» — коротко, потому что стоит в одной строке с именем. */
388
+ function ago(ms){
389
+ const sec = Math.max(0, Math.round(ms / 1000));
390
+ if (sec < 60) return sec + ' с';
391
+ const min = Math.round(sec / 60);
392
+ return min < 60 ? min + ' мин' : Math.round(min / 60) + ' ч';
393
+ }
394
+ const agoServer = iso => ago(Date.now() + skew - new Date(iso));
395
+ const serverAge = iso => Date.now() + skew - new Date(iso);
396
+
397
+ const offline = () => lastSync > 0 && Date.now() - lastSync > OFFLINE_MS;
398
+ const age = iso => `<span class="tick" data-since="${esc(iso)}"></span>`;
399
+
400
+ // Строка под лентой — «собеседник печатает»: только то, что происходит сейчас.
401
+ // Всё остальное — очередь, сбои, паузы — живёт в списке открытых обращений справа.
402
+ function activityLine(a, isOffline){
403
+ const ref = `#${a.message_id}`;
404
+ return isOffline
405
+ ? ['offline', `нет свежего подтверждения активности (${ref}): нет связи с сервером`]
406
+ : ['working', `<span class="dots">работает над ${ref}</span>`];
407
+ }
408
+
409
+ /**
410
+ * Состояние записи списка: [класс, что происходит, что мешает продолжить].
411
+ * «Что мешает» — только известное серверу. Нет сведений — так и пишем, а не
412
+ * подставляем правдоподобную причину.
413
+ */
414
+ function describe(a, isOffline){
415
+ const note = a.note ? esc(a.note) : '';
416
+ switch (a.state){
417
+ case 'queued': {
418
+ if (a.busy_with.length)
419
+ return ['queued', 'ожидает запуска', `мост занят: работает над ${a.busy_with.map(id => '#' + id).join(', ')}`];
420
+ if (a.seen_at && serverAge(a.seen_at) > SEEN_STALE_MS)
421
+ return ['attn', 'ожидает запуска', `${esc(a.name)} не выходил на связь ${age(a.seen_at)}`];
422
+ return ['queued', 'ожидает запуска', ''];
423
+ }
424
+ case 'working':
425
+ return isOffline
426
+ ? ['attn', 'нет свежего подтверждения активности', 'нет связи с сервером']
427
+ : ['working', 'работает', ''];
428
+ case 'stalled':
429
+ return ['attn', 'нет подтверждения активности',
430
+ `сигнала от моста нет с ${time(a.since)}; исход неизвестен`];
431
+ case 'answered':
432
+ return ['answered', 'ответ опубликован', 'ждёт подтверждения мостом'];
433
+ case 'finished':
434
+ return ['queued', 'завершилось без ответа', note];
435
+ case 'failed':
436
+ return ['attn', 'сбой', note];
437
+ case 'deferred':
438
+ return ['attn', 'попытки исчерпаны', `${note}. Повторить: удалить ключ «${a.mention_id}» из .bridge-state-${esc(a.handle)}.json`];
439
+ case 'paused':
440
+ return ['attn', 'Разговор приостановлен: исчерпан лимит ходов; требуется сообщение владельца', note];
441
+ default:
442
+ return ['queued', esc(a.state), note];
443
+ }
444
+ }
445
+
446
+ function agentItem(a, isOffline){
447
+ const [cls, what, why] = describe(a, isOffline);
448
+ const attempt = a.started_at && ['working','stalled','finished','failed','deferred'].includes(a.state)
449
+ ? ` · попытка ${a.attempts}, начата ${time(a.started_at)}` : '';
450
+ const reply = a.state === 'answered' && a.reply_id
451
+ ? `<a href="#msg-${a.reply_id}" data-goto="${a.reply_id}">к ответу</a>` : '';
452
+ return `<div class="item ${cls}">
453
+ <div class="top"><span class="pip" style="background:${esc(a.accent)}"></span>
454
+ <b>${esc(a.name)}</b> ← ${esc(a.question.name)} #${a.message_id}
455
+ <span class="age">ждёт ${age(a.asked_at)}</span></div>
456
+ <div class="q" title="${esc(a.question.excerpt)}">${esc(a.question.excerpt)}</div>
457
+ <div><span class="st">${what}</span><span class="why">${attempt}</span></div>
458
+ ${why ? `<div class="why">${why}</div>` : ''}
459
+ <div class="links"><a href="#msg-${a.message_id}" data-goto="${a.message_id}">к вопросу</a>${reply}</div>
460
+ </div>`;
461
+ }
462
+
463
+ function ownerItem(q){
464
+ return `<div class="item owner attn">
465
+ <div class="top"><span class="pip" style="background:${esc(q.accent)}"></span>
466
+ <b>${esc(q.name)}</b> спрашивает #${q.message_id}
467
+ <span class="age">ждёт ${age(q.asked_at)}</span></div>
468
+ <div class="q" title="${esc(q.excerpt)}">${esc(q.excerpt)}</div>
469
+ <div class="links"><a href="#msg-${q.message_id}" data-goto="${q.message_id}">к вопросу</a>
470
+ ${IS_OWNER ? `<button class="answer" data-id="${q.message_id}">ответить</button>` : ''}</div>
471
+ </div>`;
472
+ }
473
+
474
+ // Что именно показано. Пересобираем разметку, только когда меняется ЭТО, а не
475
+ // когда тикнул счётчик секунд: пересборка каждую секунду перезапускала бы
476
+ // анимацию многоточия, и оно застывало бы на первой точке — ровно тот эффект
477
+ // «всё висит», от которого индикатор и заводился.
478
+ // Ключ — снимок ЦЕЛИКОМ, а не выборка полей: новая попытка того же обращения
479
+ // меняет только since и attempts, и выборка без них оставила бы старое время начала.
480
+ // seen_at в ключ идёт не значением, а признаком «давно»: он меняется каждые 10 с.
481
+ let activityShape = '', openShape = '';
482
+
483
+ function renderActivity(){
484
+ const isOffline = offline();
485
+ const working = activityState.filter(a => a.state === 'working');
486
+ const shape = isOffline + '~' + JSON.stringify(working);
487
+
488
+ if (shape !== activityShape){
489
+ activityShape = shape;
490
+ const rows = working.map(a => {
491
+ const [cls, what] = activityLine(a, isOffline);
492
+ return `<div class="act ${cls}">
493
+ <span class="pip" style="background:${esc(a.accent)}"></span>
494
+ <span><span class="who">${esc(a.name)}</span> <span class="what">${what}</span> · начал ${time(a.since)}, <span class="tick" data-since="${esc(a.since)}"></span> назад</span>
495
+ <a class="goto" href="#msg-${a.message_id}" data-goto="${a.message_id}">к сообщению</a>
496
+ </div>`;
497
+ });
498
+ if (isOffline){
499
+ rows.unshift(`<div class="act offline"><span class="pip" style="background:var(--accent)"></span>
500
+ <span class="what">нет связи с сервером <span class="tick" data-local="1"></span> — статусы могут быть устаревшими</span></div>`);
501
+ }
502
+ activity.innerHTML = rows.join('');
503
+ }
504
+
505
+ renderOpen(isOffline);
506
+ renderBridges();
507
+
508
+ // Возраст обновляется всегда — это единственное, что меняется каждую секунду.
509
+ document.querySelectorAll('.tick').forEach(el => {
510
+ el.textContent = el.dataset.local ? ago(Date.now() - lastSync) : agoServer(el.dataset.since);
511
+ });
512
+ }
513
+
514
+ function renderOpen(isOffline){
515
+ if (!lastSync) return; // до первого ответа сервера «открытых нет» было бы неправдой
516
+ const agents = activityState.map(a => ({...a, seen_at: a.seen_at && serverAge(a.seen_at) > SEEN_STALE_MS ? a.seen_at : null}));
517
+ const shape = isOffline + '~' + JSON.stringify(agents) + JSON.stringify(ownerWait);
518
+ if (shape === openShape) return;
519
+ openShape = shape;
520
+
521
+ const parts = [];
522
+ if (isOffline) parts.push(`<div class="stale">нет связи с сервером <span class="tick" data-local="1"></span> — список может быть устаревшим</div>`);
523
+ if (ownerWait.length) parts.push('<h3>Нужен ответ владельца</h3>', ...ownerWait.map(ownerItem));
524
+ if (agents.length) parts.push('<h3>Ждут ответа агентов</h3>', ...agents.map(a => agentItem(a, isOffline)));
525
+ if (!ownerWait.length && !agents.length) parts.push('<div class="empty">Открытых обращений нет.</div>');
526
+ openList.innerHTML = parts.join('');
527
+ const n = ownerWait.length + agents.length;
528
+ openCount.textContent = n ? `· ${n}` : '';
529
+ }
530
+
531
+ let bridgesShape = '';
532
+ const bridgesEl = document.getElementById('bridges');
533
+
534
+ function renderBridges(){
535
+ if (!bridgesState) return;
536
+ const agents = bridgesState.agents.map(a => ({...a, stale: !a.seen_at || serverAge(a.seen_at) > SEEN_STALE_MS}));
537
+ const pending = bridgesState.restart_requested_at;
538
+ const stuck = !!pending && serverAge(pending) > RESTART_STUCK_MS;
539
+ const shape = JSON.stringify([agents.map(a => [a.handle, a.stale, a.stale && a.seen_at]), pending, stuck]);
540
+ if (shape === bridgesShape) return;
541
+ bridgesShape = shape;
542
+
543
+ const parts = agents.map(a => `<span class="br ${a.stale ? 'off' : ''}"
544
+ title="${a.stale ? 'мост не выходит на связь' : 'мост на связи'}">
545
+ <span class="pip" style="background:${a.stale ? 'var(--accent)' : 'var(--ok)'}"></span>@${esc(a.handle)}${
546
+ a.stale ? (a.seen_at ? ` · нет связи ${age(a.seen_at)}` : ' · не подключался') : ''}</span>`);
547
+
548
+ if (IS_OWNER){
549
+ const label = !pending ? 'перезапустить мосты' : stuck ? 'повторить перезапуск' : 'перезапуск…';
550
+ const hot = stuck || agents.some(a => a.stale) ? 'hot' : '';
551
+ parts.push(`<button type="button" id="restart" class="${hot}" ${pending && !stuck ? 'disabled' : ''}>${label}</button>`);
552
+ // Сервер сам ничего не запускает: запрос забирает systemd на хосте. Не забрал —
553
+ // говорим, где искать, а не делаем вид, что перезапуск идёт.
554
+ if (stuck) parts.push(`<span class="stuck" title="под systemd: systemctl --user status qbazar-bridge-restart.path; у пакета: team-chat status">запрос не забран: раннер не запущен или не следит за запросами</span>`);
555
+ }
556
+ bridgesEl.innerHTML = parts.join('');
557
+ }
558
+
559
+ async function restartBridges(){
560
+ const busy = activityState.filter(a => a.state === 'working');
561
+ if (busy.length && !confirm(`Сейчас работают: ${[...new Set(busy.map(a => '@' + a.handle))].join(', ')}. Перезапуск прервёт их на полуслове — продолжить?`)) return;
562
+ const r = await fetch('/api/bridges/restart', {method:'POST', headers:H});
563
+ if (!r.ok){ alert('Не удалось запросить перезапуск: ' + r.status); return; }
564
+ bridgesState = (await r.json()).bridges;
565
+ renderBridges();
566
+ }
567
+
568
+ // Чаты-проекты: где ждут ответа и где есть непрочитанное. «Прочитано» —
569
+ // последний id, который эта страница показала в чате; живёт в браузере.
570
+ let chatsState = [], chatsShape = '';
571
+ const chatList = document.getElementById('chatlist');
572
+ const readKey = slug => 'chat-read:' + slug;
573
+ function readUpTo(slug){
574
+ try { const v = localStorage.getItem(readKey(slug)); return v === null ? null : Number(v); } catch(_) { return null; }
575
+ }
576
+ function markRead(slug, id){
577
+ try { if ((readUpTo(slug) ?? 0) < id) localStorage.setItem(readKey(slug), id); } catch(_) {}
578
+ }
579
+
580
+ function renderChats(){
581
+ const rows = chatsState.map(c => {
582
+ // Чат, который этот браузер ещё не видел, непрочитанным не считаем: иначе
583
+ // первый же визит подсветил бы всю историю всех проектов.
584
+ if (readUpTo(c.slug) === null) markRead(c.slug, c.last_message_id);
585
+ const unread = c.slug !== CHAT.slug && c.last_message_id > (readUpTo(c.slug) ?? 0);
586
+ return {...c, unread};
587
+ });
588
+ const shape = JSON.stringify(rows.map(c => [c.slug, c.name, c.unread, c.owner_wait, c.agents_open]));
589
+ if (shape === chatsShape) return;
590
+ chatsShape = shape;
591
+ chatList.innerHTML = rows.map(c => `<a class="chat ${c.slug === CHAT.slug ? 'on' : ''}" href="/c/${encodeURIComponent(c.slug)}"
592
+ title="${esc(c.name)}${c.workdir ? ' · ' + esc(c.workdir) : ''}">
593
+ <span class="nm">${esc(c.name)}</span>
594
+ ${c.owner_wait ? `<span class="badge owner" title="ждут ответа владельца">${c.owner_wait}</span>` : ''}
595
+ ${c.agents_open ? `<span class="badge agents" title="ждут ответа агентов">${c.agents_open}</span>` : ''}
596
+ ${c.unread ? '<span class="unread" title="есть новые сообщения"></span>' : ''}
597
+ </a>`).join('');
598
+ }
599
+
600
+ async function poll(){
601
+ try{
602
+ const r = await fetch(API+'/messages?after_id='+lastId, {headers:H});
603
+ if (!r.ok) return;
604
+ const d = await r.json();
605
+ lastSync = Date.now();
606
+ if (d.server_time) skew = new Date(d.server_time) - lastSync;
607
+ activityState = d.activity || [];
608
+ ownerWait = d.owner_wait || [];
609
+ if (d.bridges) bridgesState = d.bridges;
610
+ if (d.chats){ chatsState = d.chats; }
611
+ renderActivity();
612
+ if (d.messages.length){
613
+ const atBottom = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120;
614
+ if (thread.querySelector('.empty')) thread.innerHTML = '';
615
+ d.messages.forEach(renderMessage);
616
+ if (atBottom) thread.scrollTop = thread.scrollHeight;
617
+ }
618
+ markRead(CHAT.slug, lastId);
619
+ renderChats();
620
+ }catch(e){ /* сеть моргнула — следующий тик подхватит, а renderActivity отметит потерю связи */ }
621
+ }
622
+
623
+ async function loadDecisions(){
624
+ try{
625
+ const r = await fetch(API+'/decisions', {headers:H});
626
+ if (!r.ok) return;
627
+ const {decisions:list} = await r.json();
628
+ decisions.innerHTML = list.length ? '' : '<div class="empty">Пока ничего не решено.</div>';
629
+ list.slice().reverse().forEach(d => {
630
+ const el = document.createElement('div');
631
+ el.className = 'dec' + (d.superseded ? ' old' : '');
632
+ el.innerHTML = `<div class="n">№${d.number}</div><div class="t">${esc(d.title)}</div>
633
+ ${d.body ? `<div class="s">${esc(d.body)}</div>` : ''}
634
+ <div class="s">${esc(d.decided_by)} · ${time(d.decided_at)}${d.supersedes ? ' · отменяет №'+d.supersedes : ''}</div>`;
635
+ decisions.appendChild(el);
636
+ });
637
+ }catch(e){}
638
+ }
639
+
640
+ const replyBar = document.getElementById('replybar');
641
+ const ta = document.getElementById('body');
642
+
643
+ async function startReply(id){
644
+ await ensureLoaded(id);
645
+ const el = document.getElementById('msg-' + id);
646
+ if (!el) return;
647
+ replyTo = id;
648
+ replyBar.querySelector('span').textContent = `↩ ответ на #${id} · ${el.dataset.author}: ${el.dataset.excerpt}`;
649
+ replyBar.classList.add('on');
650
+ // Ответ агенту без упоминания агента не разбудит: мост поднимает по @handle.
651
+ // Подставляем его заранее и на виду — стереть проще, чем вспомнить.
652
+ const tag = '@' + el.dataset.handle;
653
+ if (el.dataset.kind === 'ai' && !ta.value.includes(tag)) ta.value = tag + ' ' + ta.value;
654
+ ta.focus();
655
+ }
656
+
657
+ function cancelReply(){
658
+ replyTo = null;
659
+ replyBar.classList.remove('on');
660
+ }
661
+ document.getElementById('replycancel').addEventListener('click', cancelReply);
662
+
663
+ // Файлы, приготовленные к отправке. Уходят вместе с текстом одним сообщением.
664
+ const MAX_FILES = {{json maxFiles}};
665
+ const MAX_BYTES = {{json maxKb}} * 1024;
666
+ let pending = [];
667
+ const chips = document.getElementById('chips');
668
+ const fileInput = document.getElementById('files');
669
+
670
+ function renderChips(){
671
+ chips.innerHTML = pending.map((f, i) =>
672
+ `<span class="chip"><span title="${esc(f.name)}">📎 ${esc(f.name)} · ${fmtSize(f.size)}</span><button type="button" data-unpend="${i}" title="убрать">✕</button></span>`).join('');
673
+ chips.classList.toggle('on', pending.length > 0);
674
+ }
675
+
676
+ function addFiles(list){
677
+ for (const f of list){
678
+ if (pending.length >= MAX_FILES){ alert(`Не больше ${MAX_FILES} файлов в одном сообщении.`); break; }
679
+ if (f.size > MAX_BYTES){ alert(`«${f.name}» больше ${fmtSize(MAX_BYTES)} — не приложить.`); continue; }
680
+ pending.push(f);
681
+ }
682
+ renderChips();
683
+ ta.focus();
684
+ }
685
+
686
+ document.getElementById('attach').addEventListener('click', () => fileInput.click());
687
+ fileInput.addEventListener('change', () => { addFiles(fileInput.files); fileInput.value = ''; });
688
+ chips.addEventListener('click', e => {
689
+ const b = e.target.closest('[data-unpend]');
690
+ if (b){ pending.splice(Number(b.dataset.unpend), 1); renderChips(); }
691
+ });
692
+
693
+ // Скриншот из буфера — самый частый файл в разговоре: Ctrl+V прямо в поле.
694
+ ta.addEventListener('paste', e => {
695
+ const files = [...(e.clipboardData?.files || [])];
696
+ if (!files.length) return;
697
+ e.preventDefault();
698
+ addFiles(files.map((f, i) => f.name && f.name !== 'image.png' ? f
699
+ : new File([f], `скриншот-${new Date().toISOString().slice(0,19).replace(/[T:]/g,'-')}${i ? '-' + i : ''}.png`, {type: f.type})));
700
+ });
701
+
702
+ const dropZone = document.querySelector('.layout > .panel');
703
+ dropZone.addEventListener('dragover', e => {
704
+ if (![...e.dataTransfer.types].includes('Files')) return;
705
+ e.preventDefault();
706
+ dropZone.classList.add('drop');
707
+ });
708
+ dropZone.addEventListener('dragleave', e => { if (!dropZone.contains(e.relatedTarget)) dropZone.classList.remove('drop'); });
709
+ dropZone.addEventListener('drop', e => {
710
+ dropZone.classList.remove('drop');
711
+ if (!e.dataTransfer.files.length) return;
712
+ e.preventDefault();
713
+ addFiles(e.dataTransfer.files);
714
+ });
715
+
716
+ document.getElementById('send').addEventListener('submit', async e => {
717
+ e.preventDefault();
718
+ const body = ta.value.trim();
719
+ if (!body && !pending.length) return;
720
+ const files = pending, reply = replyTo;
721
+
722
+ let req;
723
+ if (files.length){
724
+ // multipart: Content-Type с границей ставит сам браузер.
725
+ const fd = new FormData();
726
+ if (body) fd.append('body', body);
727
+ if (reply) fd.append('reply_to_id', reply);
728
+ files.forEach(f => fd.append('files[]', f, f.name));
729
+ req = {method:'POST', headers:{'Authorization':'Bearer '+TOKEN,'Accept':'application/json'}, body:fd};
730
+ } else {
731
+ req = {method:'POST', headers:H, body:JSON.stringify(reply ? {body, reply_to_id: reply} : {body})};
732
+ }
733
+
734
+ ta.value = ''; pending = []; renderChips();
735
+ cancelReply();
736
+ const r = await fetch(API+'/messages', req).catch(() => null);
737
+ if (!r || !r.ok){
738
+ // Не теряем написанное: вернуть текст и файлы, чтобы можно было отправить снова.
739
+ let why = r ? r.status : 'нет связи';
740
+ try { why = (await r.json()).message || why; } catch(_){}
741
+ alert('Сообщение не отправлено: ' + why);
742
+ ta.value = body; pending = files; renderChips();
743
+ if (reply) startReply(reply);
744
+ return;
745
+ }
746
+ poll();
747
+ });
748
+
749
+ // Enter отправляет, Shift+Enter — перенос строки. Пока идёт набор через IME
750
+ // (isComposing), Enter подтверждает слово, а не отправляет сообщение.
751
+ ta.addEventListener('keydown', e => {
752
+ if (e.key !== 'Enter' || e.shiftKey || e.isComposing) return;
753
+ e.preventDefault();
754
+ document.getElementById('send').requestSubmit();
755
+ });
756
+
757
+ document.addEventListener('click', async e => {
758
+ const link = e.target.closest('[data-goto]');
759
+ if (link){ e.preventDefault(); goTo(Number(link.dataset.goto)); return; }
760
+
761
+ if (e.target.closest('#restart')){ restartBridges(); return; }
762
+
763
+ const img = e.target.closest('img[data-att]');
764
+ if (img && img.src){ window.open(img.src, '_blank'); return; }
765
+
766
+ const dl = e.target.closest('[data-dl]');
767
+ if (dl){
768
+ e.preventDefault();
769
+ try {
770
+ const a = Object.assign(document.createElement('a'), {href: await attachmentBlob(dl.dataset.dl), download: dl.dataset.name});
771
+ a.click();
772
+ } catch(_) { alert('Не удалось скачать файл.'); }
773
+ return;
774
+ }
775
+
776
+ const answer = e.target.closest('.reply, .answer');
777
+ if (answer){ startReply(Number(answer.dataset.id)); return; }
778
+
779
+ // Фиксация решения — только у владельца; агенты предлагают текстом в треде.
780
+ const btn = e.target.closest('.fix');
781
+ if (!btn) return;
782
+ const title = prompt('Формулировка решения (коротко и окончательно):');
783
+ if (!title) return;
784
+ const r = await fetch(API+'/decisions', {method:'POST', headers:H,
785
+ body:JSON.stringify({title, message_id:Number(btn.dataset.id)})});
786
+ if (r.ok){ seen.clear(); thread.innerHTML=''; lastId=0; poll(); loadDecisions(); }
787
+ else alert('Не удалось зафиксировать: ' + r.status);
788
+ });
789
+
790
+ // Новый чат и настройки текущего. Только у владельца: описание и каталог проекта —
791
+ // это инструкции агенту, которые уходят в каждый его запуск.
792
+ const chatDlg = document.getElementById('chatdlg');
793
+ if (chatDlg){
794
+ const form = document.getElementById('chatform');
795
+ const rolesBox = document.getElementById('chatroles');
796
+
797
+ // Роли агентов в этом чате: пусто — основная роль агента, иначе замена для чата.
798
+ async function loadChatRoles(){
799
+ const r = await fetch('/api/team', {headers:H}).catch(() => null);
800
+ if (!r || !r.ok) return;
801
+ const t = await r.json();
802
+ const agents = t.agents.filter(a => !a.disabled);
803
+ if (!agents.length) return;
804
+ rolesBox.innerHTML = '<div class="cap">Роли агентов в этом чате</div>' + agents.map(a => {
805
+ const current = (a.chat_roles || {})[CHAT.slug] || '';
806
+ return `<label class="ar"><span>@${esc(a.handle)}</span>
807
+ <select data-handle="${esc(a.handle)}" data-was="${esc(current)}">
808
+ <option value="">основная — ${esc(a.role_name)}</option>
809
+ ${t.roles.filter(x => x.slug !== a.role).map(x => `<option value="${esc(x.slug)}" ${x.slug === current ? 'selected' : ''}>${esc(x.name)}</option>`).join('')}
810
+ </select></label>`;
811
+ }).join('');
812
+ }
813
+ const err = document.getElementById('chaterr');
814
+ let editing = false;
815
+
816
+ const openDlg = edit => {
817
+ editing = edit;
818
+ form.reset(); err.textContent = '';
819
+ document.getElementById('chatdlgtitle').textContent = edit ? `Настройки чата «${CHAT.name}»` : 'Новый чат';
820
+ document.getElementById('chatsave').textContent = edit ? 'Сохранить' : 'Создать';
821
+ form.slug.disabled = edit; // адрес не меняется: на него ведут ссылки
822
+ rolesBox.innerHTML = '';
823
+ if (edit){
824
+ const c = chatsState.find(x => x.slug === CHAT.slug) || CHAT;
825
+ form.name.value = c.name; form.slug.value = c.slug;
826
+ form.workdir.value = c.workdir || ''; form.description.value = c.description || '';
827
+ loadChatRoles();
828
+ }
829
+ chatDlg.showModal();
830
+ form.name.focus();
831
+ };
832
+ document.getElementById('newchat').addEventListener('click', () => openDlg(false));
833
+ document.getElementById('editchat').addEventListener('click', () => openDlg(true));
834
+ document.getElementById('chatcancel').addEventListener('click', () => chatDlg.close());
835
+
836
+ form.addEventListener('submit', async e => {
837
+ e.preventDefault();
838
+ const data = {name: form.name.value.trim(), workdir: form.workdir.value.trim() || null,
839
+ description: form.description.value.trim() || null};
840
+ if (!editing && form.slug.value.trim()) data.slug = form.slug.value.trim();
841
+ const r = await fetch(editing ? API : '/api/chats',
842
+ {method: editing ? 'PATCH' : 'POST', headers:H, body:JSON.stringify(data)}).catch(() => null);
843
+ if (!r){ err.textContent = 'Нет связи с сервером.'; return; }
844
+ const d = await r.json().catch(() => ({}));
845
+ if (!r.ok){
846
+ err.textContent = d.errors ? Object.values(d.errors).flat().join(' ') : (d.message || 'Ошибка ' + r.status);
847
+ return;
848
+ }
849
+ for (const sel of rolesBox.querySelectorAll('select')){
850
+ if (sel.value === sel.dataset.was) continue;
851
+ const rr = await fetch(API + '/roles', {method:'PUT', headers:H,
852
+ body:JSON.stringify({handle: sel.dataset.handle, role: sel.value || null})}).catch(() => null);
853
+ if (!rr || !rr.ok){ err.textContent = `Не удалось сменить роль @${sel.dataset.handle}.`; return; }
854
+ }
855
+ location.href = '/c/' + encodeURIComponent(d.chat.slug);
856
+ });
857
+ }
858
+
859
+ poll(); loadDecisions();
860
+ setInterval(poll, POLL_MS);
861
+ setInterval(renderActivity, 1000); // возраст и потеря связи, без запросов
862
+ setInterval(loadDecisions, 15000);
863
+ </script>
864
+ </body>
865
+ </html>