agello 0.1.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.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/bin/agello.ts +262 -0
- package/package.json +35 -0
- package/src/run.ts +28 -0
- package/src/screencast.ts +179 -0
- package/src/server.ts +408 -0
- package/web/agent-bridge.js +746 -0
- package/web/embed-example.html +36 -0
- package/web/index.html +16 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
// <agent-bridge> web component.
|
|
2
|
+
//
|
|
3
|
+
// Embed:
|
|
4
|
+
// <script type="module" src="http://127.0.0.1:8765/agent-bridge.js"></script>
|
|
5
|
+
// <agent-bridge style="height: 600px"></agent-bridge>
|
|
6
|
+
//
|
|
7
|
+
// Attributes
|
|
8
|
+
// server bridge server origin (default: origin this script was loaded from)
|
|
9
|
+
// heading header text (default: "Agent Bridge")
|
|
10
|
+
// no-header hide header
|
|
11
|
+
// no-actions hide approve/reject buttons
|
|
12
|
+
// no-history do not persist chat in localStorage (default: persisted per server + pane)
|
|
13
|
+
// screen show live terminal-browser screen panel (side by side, stacked when narrow)
|
|
14
|
+
// with a toggle that lets the viewer control the browser (mouse, wheel, keyboard)
|
|
15
|
+
//
|
|
16
|
+
// Theming (CSS custom properties on the element or an ancestor)
|
|
17
|
+
// --ab-bg --ab-panel --ab-text --ab-muted --ab-line --ab-accent --ab-user --ab-font
|
|
18
|
+
//
|
|
19
|
+
// Events (bubbling, composed)
|
|
20
|
+
// agent-status detail: status object
|
|
21
|
+
// agent-message detail: { role: 'assistant'|'terminal'|'browser', text, action?, ts, id?, queued? }
|
|
22
|
+
// agent-queue detail: { phase: 'delivered'|'unqueued', id }
|
|
23
|
+
// agent-tool detail: { phase: 'start'|'end', id, name?, summary?, isError? }
|
|
24
|
+
// (tool calls are also kept in the conversation as one-line rows, like the terminal)
|
|
25
|
+
// agent-screen detail: { connected, browser?, url?, title?, agentControlled? }
|
|
26
|
+
//
|
|
27
|
+
// Methods
|
|
28
|
+
// el.send(text, action = 'message') -> Promise<{ok, error?}>
|
|
29
|
+
// el.clearHistory() clear saved chat for the current pane
|
|
30
|
+
|
|
31
|
+
const SCRIPT_ORIGIN = new URL(import.meta.url).origin;
|
|
32
|
+
|
|
33
|
+
let mdPromise;
|
|
34
|
+
function loadMarkdown() {
|
|
35
|
+
mdPromise ??= Promise.all([
|
|
36
|
+
import("https://cdn.jsdelivr.net/npm/marked/+esm"),
|
|
37
|
+
import("https://cdn.jsdelivr.net/npm/dompurify/+esm"),
|
|
38
|
+
])
|
|
39
|
+
.then(([m, p]) => (text) => p.default.sanitize(m.marked.parse(text)))
|
|
40
|
+
.catch(() => null);
|
|
41
|
+
return mdPromise;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const REASONS = {
|
|
45
|
+
agent_not_found: "세션 종료 (pane 없음)",
|
|
46
|
+
not_claude: "세션 종료 (지원 에이전트 아님)",
|
|
47
|
+
session_changed: "다른 세션으로 교체됨",
|
|
48
|
+
};
|
|
49
|
+
const STATES = { idle: "대기 중", working: "작업 중", blocked: "입력 대기", done: "완료", unknown: "상태 불명" };
|
|
50
|
+
const ACTIONS = { message: "메시지", approve: "승인", reject: "거절" };
|
|
51
|
+
|
|
52
|
+
const CSS = `
|
|
53
|
+
:host {
|
|
54
|
+
--_bg: var(--ab-bg, #f6f6f4); --_panel: var(--ab-panel, #fff); --_text: var(--ab-text, #1f1f1f);
|
|
55
|
+
--_muted: var(--ab-muted, #6b6b6b); --_line: var(--ab-line, #e4e4e0); --_accent: var(--ab-accent, #2f6fed);
|
|
56
|
+
--_user: var(--ab-user, #ececea); --_font: var(--ab-font, system-ui, -apple-system, sans-serif);
|
|
57
|
+
--_ok: #2a9d4b; --_busy: #d9a400; --_warn: #d9480f; --_dead: #c92a2a;
|
|
58
|
+
display: flex; flex-direction: column; height: 560px; min-height: 240px;
|
|
59
|
+
background: var(--_bg); color: var(--_text); font: 15px/1.55 var(--_font);
|
|
60
|
+
border: 1px solid var(--_line); border-radius: 12px; overflow: hidden; box-sizing: border-box;
|
|
61
|
+
container-type: inline-size;
|
|
62
|
+
}
|
|
63
|
+
@media (prefers-color-scheme: dark) {
|
|
64
|
+
:host {
|
|
65
|
+
--_bg: var(--ab-bg, #161616); --_panel: var(--ab-panel, #1e1e1e); --_text: var(--ab-text, #ececec);
|
|
66
|
+
--_muted: var(--ab-muted, #9a9a9a); --_line: var(--ab-line, #2e2e2e); --_user: var(--ab-user, #2f2f2f);
|
|
67
|
+
--_accent: var(--ab-accent, #3b74e6);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
:host([hidden]) { display: none; }
|
|
71
|
+
* { box-sizing: border-box; }
|
|
72
|
+
|
|
73
|
+
header { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--_line);
|
|
74
|
+
background: var(--_panel); }
|
|
75
|
+
:host([no-header]) header { display: none; }
|
|
76
|
+
h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
|
77
|
+
.clear-btn { font-size: 11px; padding: 2px 9px; border-radius: 999px; }
|
|
78
|
+
.session { margin-left: auto; display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--_muted); }
|
|
79
|
+
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--_muted); }
|
|
80
|
+
.dot.idle, .dot.done { background: var(--_ok); } .dot.working { background: var(--_busy); }
|
|
81
|
+
.dot.blocked { background: var(--_warn); } .dot.dead { background: var(--_dead); }
|
|
82
|
+
|
|
83
|
+
.body { flex: 1; display: flex; min-height: 0; }
|
|
84
|
+
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
85
|
+
.screen { display: none; }
|
|
86
|
+
:host([screen]) .screen { display: flex; flex-direction: column; flex: 1.3; min-width: 0; min-height: 0;
|
|
87
|
+
border-left: 1px solid var(--_line); background: var(--_panel); }
|
|
88
|
+
.screen-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 12px; color: var(--_muted);
|
|
89
|
+
border-bottom: 1px solid var(--_line); min-width: 0; }
|
|
90
|
+
.screen-bar .url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
|
91
|
+
.live[hidden] { display: none; }
|
|
92
|
+
.live { flex: none; font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--_line); }
|
|
93
|
+
.live.on { background: var(--_accent); border-color: var(--_accent); color: #fff; }
|
|
94
|
+
.viewport { position: relative; flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 10px;
|
|
95
|
+
background: var(--_bg); }
|
|
96
|
+
.viewport img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px;
|
|
97
|
+
box-shadow: 0 0 0 1px var(--_line); display: none; }
|
|
98
|
+
.viewport.has img { display: block; }
|
|
99
|
+
.viewport.has .placeholder { display: none; }
|
|
100
|
+
.viewport { overflow: hidden; }
|
|
101
|
+
.viewport img, .viewport .placeholder { position: relative; z-index: 1; }
|
|
102
|
+
|
|
103
|
+
/* agent control: rotating gradient ring + inner glow + badge */
|
|
104
|
+
.ring { position: absolute; inset: 0; overflow: hidden; opacity: 0; transition: opacity .35s ease;
|
|
105
|
+
pointer-events: none; z-index: 0; }
|
|
106
|
+
.ring::before { content: ""; position: absolute; inset: -150%;
|
|
107
|
+
background: conic-gradient(from 0deg, #ff3d7f, #ffb13d, #f5ff3d, #3dffb4, #3db4ff, #b43dff, #ff3d7f);
|
|
108
|
+
animation: spin 2.4s linear infinite; }
|
|
109
|
+
.ring::after { content: ""; position: absolute; inset: 4px; background: var(--_bg); border-radius: 8px; }
|
|
110
|
+
.glow { position: absolute; inset: 4px; border-radius: 8px; pointer-events: none; z-index: 2; opacity: 0;
|
|
111
|
+
transition: opacity .35s ease; }
|
|
112
|
+
.agent-badge { position: absolute; top: 14px; left: 50%; transform: translateX(-50%); z-index: 3;
|
|
113
|
+
display: flex; align-items: center; gap: 7px; padding: 5px 14px; border-radius: 999px;
|
|
114
|
+
font-size: 12px; font-weight: 600; color: #fff; letter-spacing: .02em; white-space: nowrap;
|
|
115
|
+
background: linear-gradient(90deg, #ff3d7f, #b43dff, #3db4ff, #ff3d7f); background-size: 300% 100%;
|
|
116
|
+
box-shadow: 0 4px 18px rgba(180, 61, 255, .45); opacity: 0; translate: 0 -8px; pointer-events: none;
|
|
117
|
+
transition: opacity .3s ease, translate .3s ease; }
|
|
118
|
+
.agent-badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #fff;
|
|
119
|
+
animation: blink 1s ease-in-out infinite; }
|
|
120
|
+
.viewport.agent .ring, .viewport.agent .glow { opacity: 1; }
|
|
121
|
+
.viewport.agent .glow { animation: glow 1.6s ease-in-out infinite; }
|
|
122
|
+
.viewport.agent .agent-badge { opacity: 1; translate: 0 0; animation: slide 3s linear infinite; }
|
|
123
|
+
.viewport.agent img { box-shadow: none; }
|
|
124
|
+
@keyframes glow {
|
|
125
|
+
0%, 100% { box-shadow: inset 0 0 18px 2px rgba(180, 61, 255, .35); }
|
|
126
|
+
50% { box-shadow: inset 0 0 34px 8px rgba(61, 180, 255, .55); }
|
|
127
|
+
}
|
|
128
|
+
@keyframes blink { 50% { opacity: .25; } }
|
|
129
|
+
@keyframes slide { to { background-position: 300% 0; } }
|
|
130
|
+
@media (prefers-reduced-motion: reduce) {
|
|
131
|
+
.ring::before { animation-duration: 12s; }
|
|
132
|
+
.viewport.agent .glow, .viewport.agent .agent-badge, .agent-badge::before { animation: none; }
|
|
133
|
+
}
|
|
134
|
+
.viewport.control img { box-shadow: 0 0 0 2px var(--_ok); cursor: default; }
|
|
135
|
+
.kbd { position: absolute; left: 0; top: 0; width: 1px; height: 1px; opacity: 0; border: 0; padding: 0;
|
|
136
|
+
resize: none; pointer-events: none; }
|
|
137
|
+
.control-btn { flex: none; display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 3px 4px 3px 10px;
|
|
138
|
+
border: 0; background: none; color: var(--_muted); }
|
|
139
|
+
.control-btn .state-label { min-width: 2.2em; text-align: left; font-weight: 600; }
|
|
140
|
+
.control-btn .track { position: relative; width: 34px; height: 20px; border-radius: 999px; background: var(--_line);
|
|
141
|
+
transition: background .2s ease; }
|
|
142
|
+
.control-btn .knob { position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%;
|
|
143
|
+
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .2s ease; }
|
|
144
|
+
.control-btn[aria-checked="true"] { color: var(--_text); }
|
|
145
|
+
.control-btn[aria-checked="true"] .state-label { color: var(--_ok); }
|
|
146
|
+
.control-btn[aria-checked="true"] .track { background: var(--_ok); }
|
|
147
|
+
.control-btn[aria-checked="true"] .knob { transform: translateX(14px); }
|
|
148
|
+
.control-btn:focus-visible { outline: 2px solid var(--_accent); outline-offset: 2px; border-radius: 6px; }
|
|
149
|
+
@media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
|
|
150
|
+
.placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
|
|
151
|
+
@container (max-width: 820px) {
|
|
152
|
+
.body { flex-direction: column; }
|
|
153
|
+
:host([screen]) .screen { order: -1; flex: none; height: 45%; border-left: 0; border-bottom: 1px solid var(--_line); }
|
|
154
|
+
}
|
|
155
|
+
.stage { position: relative; flex: 1; min-height: 0; display: flex; }
|
|
156
|
+
.log { flex: 1; overflow-y: auto; padding: 16px 16px 56px; }
|
|
157
|
+
.inner { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
|
|
158
|
+
.empty { text-align: center; color: var(--_muted); font-size: 13px; padding: 32px 0; }
|
|
159
|
+
|
|
160
|
+
.msg { display: flex; flex-direction: column; max-width: 85%; }
|
|
161
|
+
.meta { font-size: 11px; color: var(--_muted); margin: 0 4px 3px; }
|
|
162
|
+
.bubble { padding: 9px 13px; border-radius: 14px; overflow-wrap: anywhere; }
|
|
163
|
+
.msg.user { align-self: flex-end; align-items: flex-end; }
|
|
164
|
+
.queue:empty { display: none; }
|
|
165
|
+
.queue { margin-top: 12px; }
|
|
166
|
+
.msg.queued .bubble { opacity: .55; outline: 1px dashed var(--_muted); outline-offset: 2px; }
|
|
167
|
+
.msg.terminal .bubble { background: var(--_user); border-bottom-right-radius: 4px; white-space: pre-wrap;
|
|
168
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13.5px; }
|
|
169
|
+
.msg.browser .bubble { background: var(--_accent); color: #fff; border-bottom-right-radius: 4px; white-space: pre-wrap; }
|
|
170
|
+
.tag { display: inline-block; font-size: 11px; padding: 1px 7px; border-radius: 999px; background: rgba(255,255,255,.22); }
|
|
171
|
+
.msg.assistant { align-self: flex-start; }
|
|
172
|
+
.msg.tool-row { align-self: stretch; max-width: 100%; flex-direction: row; align-items: center; gap: 8px;
|
|
173
|
+
font-size: 12.5px; color: var(--_muted); padding: 0 4px; margin: -4px 0; }
|
|
174
|
+
.tool-row .icon { width: 7px; height: 7px; border-radius: 50%; background: var(--_muted); flex: none; }
|
|
175
|
+
.tool-row.running .icon { background: var(--_busy); animation: blink 1s ease-in-out infinite; }
|
|
176
|
+
.tool-row.done .icon { background: var(--_ok); }
|
|
177
|
+
.tool-row.error .icon { background: var(--_dead); }
|
|
178
|
+
.tool-row b { color: var(--_text); font-weight: 600; flex: none; }
|
|
179
|
+
.tool-row .sum { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
180
|
+
.msg.assistant .bubble { background: var(--_panel); border: 1px solid var(--_line); border-bottom-left-radius: 4px; }
|
|
181
|
+
.bubble.plain { white-space: pre-wrap; }
|
|
182
|
+
.bubble > :first-child { margin-top: 0; } .bubble > :last-child { margin-bottom: 0; }
|
|
183
|
+
.bubble p, .bubble ul, .bubble ol { margin: .4em 0; }
|
|
184
|
+
.bubble h1, .bubble h2, .bubble h3 { font-size: 15px; margin: .8em 0 .3em; }
|
|
185
|
+
.bubble code { font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; background: var(--_user);
|
|
186
|
+
padding: 1px 4px; border-radius: 4px; }
|
|
187
|
+
.bubble pre { background: var(--_user); padding: 8px 10px; border-radius: 8px; overflow-x: auto; }
|
|
188
|
+
.bubble pre code { background: none; padding: 0; }
|
|
189
|
+
.bubble table { border-collapse: collapse; font-size: 13px; margin: .5em 0; display: block; overflow-x: auto; }
|
|
190
|
+
.bubble th, .bubble td { border: 1px solid var(--_line); padding: 4px 8px; text-align: left; }
|
|
191
|
+
.bubble a { color: inherit; }
|
|
192
|
+
|
|
193
|
+
/* loader overlay: frosted glass over the bottom of the log, messages scroll behind it */
|
|
194
|
+
.activity { position: absolute; left: 0; right: 0; bottom: 0; padding: 8px 16px; z-index: 1;
|
|
195
|
+
background: color-mix(in srgb, var(--_bg) 55%, transparent);
|
|
196
|
+
-webkit-backdrop-filter: blur(14px) saturate(1.4); backdrop-filter: blur(14px) saturate(1.4);
|
|
197
|
+
mask-image: linear-gradient(to bottom, transparent, #000 14px);
|
|
198
|
+
animation: fadeUp .25s ease; }
|
|
199
|
+
.activity:empty { display: none; }
|
|
200
|
+
@keyframes fadeUp { from { opacity: 0; transform: translateY(6px); } }
|
|
201
|
+
@media (prefers-reduced-motion: reduce) { .activity { animation: none; } }
|
|
202
|
+
.tool { max-width: 760px; margin: 0 auto; display: flex; align-items: center; gap: 8px; font-size: 13px;
|
|
203
|
+
color: var(--_muted); padding: 4px 0; }
|
|
204
|
+
.tool b { color: var(--_text); font-weight: 600; }
|
|
205
|
+
.tool span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
206
|
+
.spinner { width: 13px; height: 13px; border-radius: 50%; border: 2px solid var(--_line);
|
|
207
|
+
border-top-color: var(--_busy); animation: spin .8s linear infinite; flex: none; }
|
|
208
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
209
|
+
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 3s; } }
|
|
210
|
+
|
|
211
|
+
footer { border-top: 1px solid var(--_line); background: var(--_panel); padding: 10px 16px 14px; }
|
|
212
|
+
.composer { max-width: 760px; margin: 0 auto; }
|
|
213
|
+
.grip { height: 14px; margin: -6px 0 4px; display: flex; align-items: center; justify-content: center;
|
|
214
|
+
cursor: ns-resize; touch-action: none; }
|
|
215
|
+
.grip::before { content: ""; width: 40px; height: 4px; border-radius: 2px; background: var(--_line);
|
|
216
|
+
transition: background .15s ease, width .15s ease; }
|
|
217
|
+
.grip:hover::before, .grip.dragging::before, .grip:focus-visible::before { background: var(--_muted); width: 56px; }
|
|
218
|
+
.grip:focus-visible { outline: none; }
|
|
219
|
+
textarea { width: 100%; min-height: 56px; resize: none; font: inherit; padding: 9px 12px;
|
|
220
|
+
border: 1px solid var(--_line); border-radius: 10px; background: var(--_bg); color: var(--_text); }
|
|
221
|
+
textarea:focus { outline: 2px solid var(--_accent); outline-offset: -1px; }
|
|
222
|
+
.row { display: flex; gap: 8px; margin-top: 8px; align-items: center; }
|
|
223
|
+
button { font: inherit; font-size: 13px; padding: 6px 13px; border-radius: 8px; cursor: pointer;
|
|
224
|
+
border: 1px solid var(--_line); background: var(--_panel); color: var(--_text); }
|
|
225
|
+
button.primary { background: var(--_accent); border-color: var(--_accent); color: #fff; margin-left: auto; }
|
|
226
|
+
button:disabled { cursor: not-allowed; opacity: .45; }
|
|
227
|
+
:host([no-actions]) .secondary { display: none; }
|
|
228
|
+
.hint { font-size: 12px; color: var(--_muted); }
|
|
229
|
+
`;
|
|
230
|
+
|
|
231
|
+
const TEMPLATE = `
|
|
232
|
+
<header><h1 part="heading"></h1><div class="session"><button class="clear-btn" title="이 pane의 저장된 대화 삭제">기록 지우기</button><span class="dot"></span><span class="state">연결 중</span></div></header>
|
|
233
|
+
<div class="body"><section class="chat">
|
|
234
|
+
<div class="stage">
|
|
235
|
+
<div class="log" part="log"><div class="inner"><div class="empty">이 pane의 대화가 브라우저에 저장돼요.</div></div><div class="inner queue" part="queue"></div></div>
|
|
236
|
+
<div class="activity" part="activity"></div>
|
|
237
|
+
</div>
|
|
238
|
+
<footer part="composer"><div class="composer">
|
|
239
|
+
<div class="grip" role="separator" aria-orientation="horizontal" aria-label="입력창 높이 조절 (위아래 방향키)" tabindex="0" title="끌어서 입력창 높이 조절"></div>
|
|
240
|
+
<textarea placeholder="에이전트에게 보낼 메시지 (Enter 전송, Shift+Enter 줄바꿈)"></textarea>
|
|
241
|
+
<div class="row">
|
|
242
|
+
<button class="secondary" data-action="approve">승인</button>
|
|
243
|
+
<button class="secondary" data-action="reject">거절</button>
|
|
244
|
+
<span class="hint"></span>
|
|
245
|
+
<button class="primary" data-action="message">전송</button>
|
|
246
|
+
</div>
|
|
247
|
+
</div></footer>
|
|
248
|
+
</section>
|
|
249
|
+
<aside class="screen" part="screen">
|
|
250
|
+
<div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
|
|
251
|
+
<button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
|
|
252
|
+
<span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
|
|
253
|
+
<div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div></div>
|
|
254
|
+
</aside></div>`;
|
|
255
|
+
|
|
256
|
+
class AgentBridge extends HTMLElement {
|
|
257
|
+
static observedAttributes = ["heading", "server", "screen"];
|
|
258
|
+
|
|
259
|
+
#root = this.attachShadow({ mode: "open" });
|
|
260
|
+
#es = null;
|
|
261
|
+
#screenEs = null;
|
|
262
|
+
#controlWs = null;
|
|
263
|
+
#frame = { w: 0, h: 0 };
|
|
264
|
+
#moveQueued = null;
|
|
265
|
+
#status = null;
|
|
266
|
+
#tools = new Map();
|
|
267
|
+
#md = null;
|
|
268
|
+
#historyKey = null;
|
|
269
|
+
#history = [];
|
|
270
|
+
static HISTORY_LIMIT = 500;
|
|
271
|
+
|
|
272
|
+
constructor() {
|
|
273
|
+
super();
|
|
274
|
+
this.#root.innerHTML = `<style>${CSS}</style>${TEMPLATE}`;
|
|
275
|
+
const q = (s) => this.#root.querySelector(s);
|
|
276
|
+
this.$ = {
|
|
277
|
+
heading: q("h1"), dot: q(".dot"), state: q(".state"), log: q(".log"), inner: q(".inner"), queue: q(".queue"),
|
|
278
|
+
activity: q(".activity"), box: q("textarea"), hint: q(".hint"),
|
|
279
|
+
buttons: this.#root.querySelectorAll("button[data-action]"),
|
|
280
|
+
live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
|
|
281
|
+
controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
|
|
282
|
+
};
|
|
283
|
+
this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
|
|
284
|
+
this.#bindGrip();
|
|
285
|
+
this.$.box.addEventListener("keydown", (e) => {
|
|
286
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
this.#submit("message");
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
loadMarkdown().then((md) => (this.#md = md));
|
|
292
|
+
this.#bindControl();
|
|
293
|
+
this.$.clearBtn.addEventListener("click", () => this.clearHistory());
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
get server() {
|
|
297
|
+
return (this.getAttribute("server") || SCRIPT_ORIGIN).replace(/\/$/, "");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
connectedCallback() {
|
|
301
|
+
this.$.heading.textContent = this.getAttribute("heading") || "Agent Bridge";
|
|
302
|
+
this.#connect();
|
|
303
|
+
this.#syncScreen();
|
|
304
|
+
}
|
|
305
|
+
disconnectedCallback() {
|
|
306
|
+
this.#es?.close();
|
|
307
|
+
this.#es = null;
|
|
308
|
+
this.#screenEs?.close();
|
|
309
|
+
this.#screenEs = null;
|
|
310
|
+
this.#setControl(false);
|
|
311
|
+
}
|
|
312
|
+
attributeChangedCallback(name) {
|
|
313
|
+
if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "Agent Bridge";
|
|
314
|
+
if (name === "server" && this.isConnected) { this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#syncScreen(); }
|
|
315
|
+
if (name === "screen" && this.isConnected) this.#syncScreen();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async send(text, action = "message") {
|
|
319
|
+
try {
|
|
320
|
+
const res = await fetch(`${this.server}/send`, {
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: { "Content-Type": "application/json" },
|
|
323
|
+
body: JSON.stringify({ action, text }),
|
|
324
|
+
});
|
|
325
|
+
return await res.json();
|
|
326
|
+
} catch {
|
|
327
|
+
return { ok: false, error: "server_unreachable" };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#emit(type, detail) {
|
|
332
|
+
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#connect() {
|
|
336
|
+
this.#es?.close();
|
|
337
|
+
const es = (this.#es = new EventSource(`${this.server}/events`));
|
|
338
|
+
const on = (name, fn) => es.addEventListener(name, (e) => fn(JSON.parse(e.data)));
|
|
339
|
+
on("hello", (d) => {
|
|
340
|
+
if (!d.transcript) this.$.hint.textContent = "대화 기록 파일 없음: 메시지 표시 불가";
|
|
341
|
+
if (d.pane) this.#loadHistory(d.pane, d.queue ?? [], d.tools ?? []);
|
|
342
|
+
});
|
|
343
|
+
on("delivered", ({ id }) => { this.#deliver(id); this.#emit("agent-queue", { phase: "delivered", id }); });
|
|
344
|
+
on("unqueued", ({ id }) => { this.#unqueue(id); this.#emit("agent-queue", { phase: "unqueued", id }); });
|
|
345
|
+
on("status", (s) => this.#renderStatus(s));
|
|
346
|
+
on("message", (m) => { this.#addMessage(m); this.#saveMessage(m); this.#emit("agent-message", m); });
|
|
347
|
+
on("tool_start", (t) => {
|
|
348
|
+
this.#tools.set(t.id, t);
|
|
349
|
+
this.#renderActivity();
|
|
350
|
+
if (!this.#row(t.id)) {
|
|
351
|
+
const m = { role: "tool", id: t.id, name: t.name, summary: t.summary, ts: t.ts, state: "running" };
|
|
352
|
+
this.#addMessage(m);
|
|
353
|
+
this.#saveMessage(m);
|
|
354
|
+
}
|
|
355
|
+
this.#emit("agent-tool", { phase: "start", ...t });
|
|
356
|
+
});
|
|
357
|
+
on("tool_end", (t) => {
|
|
358
|
+
this.#tools.delete(t.id);
|
|
359
|
+
this.#renderActivity();
|
|
360
|
+
this.#setToolState(t.id, t.isError ? "error" : "done");
|
|
361
|
+
this.#emit("agent-tool", { phase: "end", ...t });
|
|
362
|
+
});
|
|
363
|
+
es.onerror = () => this.#renderStatus(null);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#syncScreen() {
|
|
367
|
+
const want = this.hasAttribute("screen");
|
|
368
|
+
if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
|
|
369
|
+
if (this.#screenEs) return;
|
|
370
|
+
const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
|
|
371
|
+
es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
|
|
372
|
+
es.addEventListener("frame", (e) => {
|
|
373
|
+
const f = JSON.parse(e.data);
|
|
374
|
+
this.#frame = { w: f.w, h: f.h };
|
|
375
|
+
this.$.img.src = `data:image/jpeg;base64,${f.data}`;
|
|
376
|
+
this.$.viewport.classList.add("has");
|
|
377
|
+
});
|
|
378
|
+
es.onerror = () => this.#renderScreenMeta({ connected: false });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------- composer resize grip (top edge) ----------
|
|
382
|
+
|
|
383
|
+
#bindGrip() {
|
|
384
|
+
const grip = this.#root.querySelector(".grip");
|
|
385
|
+
const box = this.$.box;
|
|
386
|
+
const MIN = 56;
|
|
387
|
+
const max = () => Math.max(MIN, Math.round(this.clientHeight * 0.6));
|
|
388
|
+
const setH = (h) => { box.style.height = `${Math.min(max(), Math.max(MIN, Math.round(h)))}px`; };
|
|
389
|
+
let startY = 0, startH = 0;
|
|
390
|
+
|
|
391
|
+
grip.addEventListener("pointerdown", (e) => {
|
|
392
|
+
e.preventDefault();
|
|
393
|
+
startY = e.clientY;
|
|
394
|
+
startH = box.offsetHeight;
|
|
395
|
+
grip.setPointerCapture(e.pointerId);
|
|
396
|
+
grip.classList.add("dragging");
|
|
397
|
+
});
|
|
398
|
+
grip.addEventListener("pointermove", (e) => {
|
|
399
|
+
if (grip.hasPointerCapture(e.pointerId)) setH(startH + (startY - e.clientY));
|
|
400
|
+
});
|
|
401
|
+
const end = (e) => {
|
|
402
|
+
if (grip.hasPointerCapture(e.pointerId)) grip.releasePointerCapture(e.pointerId);
|
|
403
|
+
grip.classList.remove("dragging");
|
|
404
|
+
};
|
|
405
|
+
grip.addEventListener("pointerup", end);
|
|
406
|
+
grip.addEventListener("pointercancel", end);
|
|
407
|
+
grip.addEventListener("dblclick", () => { box.style.height = ""; });
|
|
408
|
+
grip.addEventListener("keydown", (e) => {
|
|
409
|
+
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
|
410
|
+
e.preventDefault();
|
|
411
|
+
setH(box.offsetHeight + (e.key === "ArrowUp" ? 24 : -24));
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------- user control ----------
|
|
417
|
+
|
|
418
|
+
#setControl(on) {
|
|
419
|
+
if (on && !this.#controlWs) {
|
|
420
|
+
const ws = new WebSocket(`${this.server.replace(/^http/, "ws")}/input`);
|
|
421
|
+
ws.onclose = () => { if (this.#controlWs === ws) this.#setControl(false); };
|
|
422
|
+
this.#controlWs = ws;
|
|
423
|
+
} else if (!on && this.#controlWs) {
|
|
424
|
+
const ws = this.#controlWs;
|
|
425
|
+
this.#controlWs = null;
|
|
426
|
+
ws.close();
|
|
427
|
+
}
|
|
428
|
+
this.$.controlBtn.setAttribute("aria-checked", String(!!this.#controlWs));
|
|
429
|
+
this.$.controlBtn.querySelector(".state-label").textContent = this.#controlWs ? "ON" : "OFF";
|
|
430
|
+
this.$.viewport.classList.toggle("control", !!this.#controlWs);
|
|
431
|
+
if (this.#controlWs) this.$.kbd.focus();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
#cdp(method, params) {
|
|
435
|
+
const ws = this.#controlWs;
|
|
436
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ method, params }));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
#point(e) {
|
|
440
|
+
const r = this.$.img.getBoundingClientRect();
|
|
441
|
+
return {
|
|
442
|
+
x: Math.round(((e.clientX - r.left) / r.width) * this.#frame.w),
|
|
443
|
+
y: Math.round(((e.clientY - r.top) / r.height) * this.#frame.h),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
#bindControl() {
|
|
448
|
+
const { img, kbd, controlBtn } = this.$;
|
|
449
|
+
const on = () => !!this.#controlWs && this.#frame.w > 0;
|
|
450
|
+
const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
|
451
|
+
const BUTTONS = ["left", "middle", "right"];
|
|
452
|
+
|
|
453
|
+
controlBtn.addEventListener("click", () => this.#setControl(!this.#controlWs));
|
|
454
|
+
|
|
455
|
+
img.addEventListener("mousedown", (e) => {
|
|
456
|
+
if (!on()) return;
|
|
457
|
+
e.preventDefault();
|
|
458
|
+
kbd.focus();
|
|
459
|
+
this.#cdp("Input.dispatchMouseEvent", { type: "mousePressed", ...this.#point(e), button: BUTTONS[e.button] ?? "left",
|
|
460
|
+
buttons: e.buttons, clickCount: e.detail || 1, modifiers: mods(e) });
|
|
461
|
+
});
|
|
462
|
+
img.addEventListener("mouseup", (e) => {
|
|
463
|
+
if (!on()) return;
|
|
464
|
+
e.preventDefault();
|
|
465
|
+
this.#cdp("Input.dispatchMouseEvent", { type: "mouseReleased", ...this.#point(e), button: BUTTONS[e.button] ?? "left",
|
|
466
|
+
buttons: e.buttons, clickCount: e.detail || 1, modifiers: mods(e) });
|
|
467
|
+
});
|
|
468
|
+
img.addEventListener("mousemove", (e) => {
|
|
469
|
+
if (!on()) return;
|
|
470
|
+
const first = !this.#moveQueued;
|
|
471
|
+
this.#moveQueued = e;
|
|
472
|
+
if (!first) return;
|
|
473
|
+
requestAnimationFrame(() => {
|
|
474
|
+
const ev = this.#moveQueued;
|
|
475
|
+
this.#moveQueued = null;
|
|
476
|
+
this.#cdp("Input.dispatchMouseEvent", { type: "mouseMoved", ...this.#point(ev),
|
|
477
|
+
button: ev.buttons & 1 ? "left" : "none", buttons: ev.buttons, modifiers: mods(ev) });
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
img.addEventListener("wheel", (e) => {
|
|
481
|
+
if (!on()) return;
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
this.#cdp("Input.dispatchMouseEvent", { type: "mouseWheel", ...this.#point(e), deltaX: e.deltaX, deltaY: e.deltaY,
|
|
484
|
+
modifiers: mods(e) });
|
|
485
|
+
}, { passive: false });
|
|
486
|
+
img.addEventListener("contextmenu", (e) => { if (on()) e.preventDefault(); });
|
|
487
|
+
|
|
488
|
+
// Keyboard goes through a hidden textarea so IME (Korean) composition works.
|
|
489
|
+
const keyParams = (e) => ({ key: e.key, code: e.code, windowsVirtualKeyCode: e.keyCode,
|
|
490
|
+
nativeVirtualKeyCode: e.keyCode, modifiers: mods(e) });
|
|
491
|
+
kbd.addEventListener("keydown", (e) => {
|
|
492
|
+
if (!on() || e.isComposing || e.keyCode === 229) return;
|
|
493
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "v") return; // handled by paste
|
|
494
|
+
e.preventDefault();
|
|
495
|
+
if (e.key === "Escape" && e.shiftKey) return this.#setControl(false);
|
|
496
|
+
const printable = e.key.length === 1 && !e.ctrlKey && !e.metaKey;
|
|
497
|
+
if (printable) this.#cdp("Input.dispatchKeyEvent", { type: "keyDown", ...keyParams(e), text: e.key, unmodifiedText: e.key });
|
|
498
|
+
else if (e.key === "Enter") this.#cdp("Input.dispatchKeyEvent", { type: "keyDown", ...keyParams(e), text: "\r", unmodifiedText: "\r" });
|
|
499
|
+
else this.#cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", ...keyParams(e) });
|
|
500
|
+
});
|
|
501
|
+
kbd.addEventListener("keyup", (e) => {
|
|
502
|
+
if (!on() || e.isComposing || e.keyCode === 229) return;
|
|
503
|
+
e.preventDefault();
|
|
504
|
+
this.#cdp("Input.dispatchKeyEvent", { type: "keyUp", ...keyParams(e) });
|
|
505
|
+
});
|
|
506
|
+
kbd.addEventListener("compositionend", (e) => {
|
|
507
|
+
if (on() && e.data) this.#cdp("Input.insertText", { text: e.data });
|
|
508
|
+
kbd.value = "";
|
|
509
|
+
});
|
|
510
|
+
kbd.addEventListener("input", (e) => { if (!e.isComposing) kbd.value = ""; });
|
|
511
|
+
kbd.addEventListener("paste", (e) => {
|
|
512
|
+
if (!on()) return;
|
|
513
|
+
e.preventDefault();
|
|
514
|
+
const text = e.clipboardData?.getData("text/plain");
|
|
515
|
+
if (text) this.#cdp("Input.insertText", { text });
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#renderScreenMeta(m) {
|
|
520
|
+
const { live, url, viewport } = this.$;
|
|
521
|
+
if (!m.connected) {
|
|
522
|
+
live.hidden = true;
|
|
523
|
+
url.textContent = "";
|
|
524
|
+
viewport.classList.remove("has", "agent");
|
|
525
|
+
this.$.img.removeAttribute("src");
|
|
526
|
+
} else {
|
|
527
|
+
live.hidden = !m.agentControlled;
|
|
528
|
+
live.classList.add("on");
|
|
529
|
+
viewport.classList.toggle("agent", !!m.agentControlled);
|
|
530
|
+
url.textContent = m.title ? `${m.title} · ${m.url}` : m.url || "";
|
|
531
|
+
url.title = m.url || "";
|
|
532
|
+
}
|
|
533
|
+
this.#emit("agent-screen", m);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ---------- history (localStorage, per server + pane) ----------
|
|
537
|
+
|
|
538
|
+
async #loadHistory(pane, serverQueue = [], serverTools = []) {
|
|
539
|
+
if (this.hasAttribute("no-history")) return;
|
|
540
|
+
const key = `agent-bridge:${this.server}:${pane}`;
|
|
541
|
+
if (key === this.#historyKey) return;
|
|
542
|
+
this.#historyKey = key;
|
|
543
|
+
try {
|
|
544
|
+
this.#history = JSON.parse(localStorage.getItem(key) || "[]");
|
|
545
|
+
} catch {
|
|
546
|
+
this.#history = [];
|
|
547
|
+
}
|
|
548
|
+
this.#md ??= await loadMarkdown();
|
|
549
|
+
if (key !== this.#historyKey) return;
|
|
550
|
+
// Saved "queued" messages the server no longer has queued were delivered
|
|
551
|
+
// (or dropped) while this page was closed: show them as delivered.
|
|
552
|
+
for (const m of this.#history) if (m.queued && !serverQueue.includes(m.id)) m.queued = false;
|
|
553
|
+
// Same for tool rows saved as running: finished while the page was closed.
|
|
554
|
+
for (const m of this.#history)
|
|
555
|
+
if (m.role === "tool" && m.state === "running" && !serverTools.includes(m.id)) m.state = "done";
|
|
556
|
+
this.#persist();
|
|
557
|
+
this.#resetLog();
|
|
558
|
+
for (const m of this.#history) this.#addMessage(m);
|
|
559
|
+
this.$.log.scrollTop = this.$.log.scrollHeight;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Queued prompt reached the agent: move it from the queue area into the
|
|
563
|
+
// conversation at the current position, like the terminal does.
|
|
564
|
+
#deliver(id) {
|
|
565
|
+
const el = this.#queued(id);
|
|
566
|
+
const i = this.#history.findIndex((m) => m.id === id);
|
|
567
|
+
const m = i >= 0 ? this.#history.splice(i, 1)[0] : el?.__msg;
|
|
568
|
+
el?.remove();
|
|
569
|
+
if (!m) return;
|
|
570
|
+
m.queued = false;
|
|
571
|
+
this.#addMessage(m);
|
|
572
|
+
this.#history.push(m);
|
|
573
|
+
this.#persist();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// (lookup by iteration: the module-level `CSS` style string shadows window.CSS)
|
|
577
|
+
#queued(id) {
|
|
578
|
+
return [...this.$.queue.children].find((el) => el.dataset.id === id) ?? null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
#row(id) {
|
|
582
|
+
return [...this.$.inner.children].find((el) => el.dataset.id === id) ?? null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
#setToolState(id, state) {
|
|
586
|
+
const el = this.#row(id);
|
|
587
|
+
if (el) el.className = `msg tool-row ${state}`;
|
|
588
|
+
const m = this.#history.find((x) => x.id === id);
|
|
589
|
+
if (m) {
|
|
590
|
+
m.state = state;
|
|
591
|
+
this.#persist();
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
#unqueue(id) {
|
|
596
|
+
this.#queued(id)?.remove();
|
|
597
|
+
this.#history = this.#history.filter((m) => m.id !== id);
|
|
598
|
+
this.#persist();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
#saveMessage(m) {
|
|
602
|
+
this.#history.push(m);
|
|
603
|
+
this.#persist();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
#persist() {
|
|
607
|
+
if (!this.#historyKey) return;
|
|
608
|
+
if (this.#history.length > AgentBridge.HISTORY_LIMIT)
|
|
609
|
+
this.#history = this.#history.slice(-AgentBridge.HISTORY_LIMIT);
|
|
610
|
+
for (;;) {
|
|
611
|
+
try {
|
|
612
|
+
localStorage.setItem(this.#historyKey, JSON.stringify(this.#history));
|
|
613
|
+
return;
|
|
614
|
+
} catch {
|
|
615
|
+
if (this.#history.length <= 1) return; // quota: drop oldest half and retry
|
|
616
|
+
this.#history = this.#history.slice(Math.floor(this.#history.length / 2));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
clearHistory() {
|
|
622
|
+
if (this.#historyKey) localStorage.removeItem(this.#historyKey);
|
|
623
|
+
this.#history = [];
|
|
624
|
+
this.#resetLog();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
#resetLog() {
|
|
628
|
+
const empty = document.createElement("div");
|
|
629
|
+
empty.className = "empty";
|
|
630
|
+
empty.textContent = "이 pane의 대화가 브라우저에 저장돼요.";
|
|
631
|
+
this.$.inner.replaceChildren(empty);
|
|
632
|
+
this.$.queue.replaceChildren();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
#nearBottom() {
|
|
636
|
+
const l = this.$.log;
|
|
637
|
+
return l.scrollHeight - l.scrollTop - l.clientHeight < 80;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
#addMessage(m) {
|
|
641
|
+
this.$.inner.querySelector(".empty")?.remove();
|
|
642
|
+
const stick = this.#nearBottom();
|
|
643
|
+
const time = new Date(m.ts || Date.now()).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" });
|
|
644
|
+
const wrap = document.createElement("div");
|
|
645
|
+
const meta = document.createElement("div");
|
|
646
|
+
const bubble = document.createElement("div");
|
|
647
|
+
meta.className = "meta";
|
|
648
|
+
bubble.className = "bubble";
|
|
649
|
+
bubble.part = "bubble";
|
|
650
|
+
|
|
651
|
+
if (m.role === "tool") {
|
|
652
|
+
wrap.className = `msg tool-row ${m.state || "done"}`;
|
|
653
|
+
const icon = document.createElement("span");
|
|
654
|
+
icon.className = "icon";
|
|
655
|
+
const name = document.createElement("b");
|
|
656
|
+
name.textContent = m.name;
|
|
657
|
+
const sum = document.createElement("span");
|
|
658
|
+
sum.className = "sum";
|
|
659
|
+
sum.textContent = m.summary || "";
|
|
660
|
+
sum.title = m.summary || "";
|
|
661
|
+
wrap.append(icon, name, sum);
|
|
662
|
+
wrap.dataset.id = m.id;
|
|
663
|
+
wrap.__msg = m;
|
|
664
|
+
this.$.inner.append(wrap);
|
|
665
|
+
if (stick) this.$.log.scrollTop = this.$.log.scrollHeight;
|
|
666
|
+
return;
|
|
667
|
+
} else if (m.role === "assistant") {
|
|
668
|
+
wrap.className = "msg assistant";
|
|
669
|
+
meta.textContent = `에이전트 · ${time}`;
|
|
670
|
+
if (this.#md) bubble.innerHTML = this.#md(m.text);
|
|
671
|
+
else { bubble.textContent = m.text; bubble.classList.add("plain"); }
|
|
672
|
+
} else if (m.role === "browser") {
|
|
673
|
+
wrap.className = "msg user browser";
|
|
674
|
+
meta.textContent = `브라우저 · ${time}${m.queued ? " · 대기 중" : ""}`;
|
|
675
|
+
if (m.action !== "message") {
|
|
676
|
+
const tag = document.createElement("span");
|
|
677
|
+
tag.className = "tag";
|
|
678
|
+
tag.textContent = ACTIONS[m.action] || m.action;
|
|
679
|
+
bubble.append(tag, m.text ? "\n" : "");
|
|
680
|
+
}
|
|
681
|
+
if (m.text) bubble.append(m.text);
|
|
682
|
+
} else {
|
|
683
|
+
wrap.className = "msg user terminal";
|
|
684
|
+
meta.textContent = `터미널 · ${time}${m.queued ? " · 대기 중" : ""}`;
|
|
685
|
+
bubble.textContent = m.text;
|
|
686
|
+
}
|
|
687
|
+
wrap.append(meta, bubble);
|
|
688
|
+
wrap.__msg = m;
|
|
689
|
+
if (m.id) wrap.dataset.id = m.id;
|
|
690
|
+
if (m.queued) {
|
|
691
|
+
wrap.classList.add("queued");
|
|
692
|
+
this.$.queue.append(wrap); // pinned below the conversation until delivered
|
|
693
|
+
} else {
|
|
694
|
+
this.$.inner.append(wrap);
|
|
695
|
+
}
|
|
696
|
+
if (stick || m.role !== "assistant") this.$.log.scrollTop = this.$.log.scrollHeight;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
#renderActivity() {
|
|
700
|
+
const rows = [...this.#tools.values()];
|
|
701
|
+
const s = this.#status;
|
|
702
|
+
if (!rows.length && s?.alive && s.status === "working") rows.push({ name: "생각 중", summary: "" });
|
|
703
|
+
this.$.activity.replaceChildren(
|
|
704
|
+
...rows.map((t) => {
|
|
705
|
+
const row = document.createElement("div");
|
|
706
|
+
row.className = "tool";
|
|
707
|
+
const sp = document.createElement("span");
|
|
708
|
+
sp.className = "spinner";
|
|
709
|
+
const name = document.createElement("b");
|
|
710
|
+
name.textContent = t.name;
|
|
711
|
+
const sum = document.createElement("span");
|
|
712
|
+
sum.textContent = t.summary || "";
|
|
713
|
+
row.append(sp, name, sum);
|
|
714
|
+
return row;
|
|
715
|
+
}),
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
#renderStatus(s) {
|
|
720
|
+
this.#status = s;
|
|
721
|
+
let cls, text, canSend;
|
|
722
|
+
if (!s) { cls = "dead"; text = "서버 연결 끊김"; canSend = false; }
|
|
723
|
+
else if (!s.alive) { cls = "dead"; text = REASONS[s.reason] || s.reason; canSend = false; }
|
|
724
|
+
else { cls = s.status; canSend = s.status !== "blocked"; text = `${STATES[s.status] || s.status} · ${s.label || s.pane}`; }
|
|
725
|
+
this.$.dot.className = `dot ${cls}`;
|
|
726
|
+
this.$.state.textContent = text;
|
|
727
|
+
this.$.state.title = s?.pane ? `pane: ${s.pane}` + (s.session ? `\nsession: ${s.session}` : "") : "";
|
|
728
|
+
this.$.buttons.forEach((b) => (b.disabled = !canSend));
|
|
729
|
+
if (!s || !s.alive || s.status === "idle") this.#tools.clear();
|
|
730
|
+
this.#renderActivity();
|
|
731
|
+
this.#emit("agent-status", s);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async #submit(action) {
|
|
735
|
+
const text = this.$.box.value.trim();
|
|
736
|
+
if (action === "message" && !text) return;
|
|
737
|
+
this.$.buttons.forEach((b) => (b.disabled = true));
|
|
738
|
+
const res = await this.send(text, action);
|
|
739
|
+
if (res.ok) { this.$.box.value = ""; this.$.hint.textContent = ""; }
|
|
740
|
+
else this.$.hint.textContent = `전송 실패: ${res.error}`;
|
|
741
|
+
this.#renderStatus(this.#status);
|
|
742
|
+
this.$.box.focus();
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (!customElements.get("agent-bridge")) customElements.define("agent-bridge", AgentBridge);
|