agent-tool-forge 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.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/lib/agent-registry.js +170 -0
- package/lib/api-client.js +792 -0
- package/lib/api-loader.js +260 -0
- package/lib/auth.d.ts +25 -0
- package/lib/auth.js +158 -0
- package/lib/checks/check-adapter.js +172 -0
- package/lib/checks/compose.js +42 -0
- package/lib/checks/content-match.js +14 -0
- package/lib/checks/cost-budget.js +11 -0
- package/lib/checks/index.js +18 -0
- package/lib/checks/json-valid.js +15 -0
- package/lib/checks/latency.js +11 -0
- package/lib/checks/length-bounds.js +17 -0
- package/lib/checks/negative-match.js +14 -0
- package/lib/checks/no-hallucinated-numbers.js +63 -0
- package/lib/checks/non-empty.js +34 -0
- package/lib/checks/regex-match.js +12 -0
- package/lib/checks/run-checks.js +84 -0
- package/lib/checks/schema-match.js +26 -0
- package/lib/checks/tool-call-count.js +16 -0
- package/lib/checks/tool-selection.js +34 -0
- package/lib/checks/types.js +45 -0
- package/lib/comparison/compare.js +86 -0
- package/lib/comparison/format.js +104 -0
- package/lib/comparison/index.js +6 -0
- package/lib/comparison/statistics.js +59 -0
- package/lib/comparison/types.js +41 -0
- package/lib/config-schema.js +200 -0
- package/lib/config.d.ts +66 -0
- package/lib/conversation-store.d.ts +77 -0
- package/lib/conversation-store.js +443 -0
- package/lib/db.d.ts +6 -0
- package/lib/db.js +1112 -0
- package/lib/dep-check.js +99 -0
- package/lib/drift-background.js +61 -0
- package/lib/drift-monitor.js +187 -0
- package/lib/eval-runner.js +566 -0
- package/lib/fixtures/fixture-store.js +161 -0
- package/lib/fixtures/index.js +11 -0
- package/lib/forge-engine.js +982 -0
- package/lib/forge-eval-generator.js +417 -0
- package/lib/forge-file-writer.js +386 -0
- package/lib/forge-service-client.js +190 -0
- package/lib/forge-service.d.ts +4 -0
- package/lib/forge-service.js +655 -0
- package/lib/forge-verifier-generator.js +271 -0
- package/lib/handlers/admin.js +151 -0
- package/lib/handlers/agents.js +229 -0
- package/lib/handlers/chat-resume.js +334 -0
- package/lib/handlers/chat-sync.js +320 -0
- package/lib/handlers/chat.js +320 -0
- package/lib/handlers/conversations.js +92 -0
- package/lib/handlers/preferences.js +88 -0
- package/lib/handlers/tools-list.js +58 -0
- package/lib/hitl-engine.d.ts +60 -0
- package/lib/hitl-engine.js +261 -0
- package/lib/http-utils.js +92 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +141 -0
- package/lib/init.js +636 -0
- package/lib/manual-entry.js +59 -0
- package/lib/mcp-server.js +252 -0
- package/lib/output-groups.js +54 -0
- package/lib/postgres-store.d.ts +31 -0
- package/lib/postgres-store.js +465 -0
- package/lib/preference-store.d.ts +47 -0
- package/lib/preference-store.js +79 -0
- package/lib/prompt-store.d.ts +42 -0
- package/lib/prompt-store.js +60 -0
- package/lib/rate-limiter.d.ts +30 -0
- package/lib/rate-limiter.js +104 -0
- package/lib/react-engine.d.ts +110 -0
- package/lib/react-engine.js +337 -0
- package/lib/runner/cli.js +156 -0
- package/lib/runner/cost-estimator.js +71 -0
- package/lib/runner/gate.js +46 -0
- package/lib/runner/index.js +165 -0
- package/lib/sidecar.d.ts +83 -0
- package/lib/sidecar.js +161 -0
- package/lib/sse.d.ts +15 -0
- package/lib/sse.js +30 -0
- package/lib/tools-scanner.js +91 -0
- package/lib/tui.js +253 -0
- package/lib/verifier-report.js +78 -0
- package/lib/verifier-runner.js +338 -0
- package/lib/verifier-scanner.js +70 -0
- package/lib/verifier-worker-pool.js +196 -0
- package/lib/views/chat.js +340 -0
- package/lib/views/endpoints.js +203 -0
- package/lib/views/eval-run.js +206 -0
- package/lib/views/forge-agent.js +538 -0
- package/lib/views/forge.js +410 -0
- package/lib/views/main-menu.js +275 -0
- package/lib/views/mediation.js +381 -0
- package/lib/views/model-compare.js +430 -0
- package/lib/views/model-comparison.js +333 -0
- package/lib/views/onboarding.js +470 -0
- package/lib/views/performance.js +237 -0
- package/lib/views/run-evals.js +205 -0
- package/lib/views/settings.js +829 -0
- package/lib/views/tools-evals.js +514 -0
- package/lib/views/verifier-coverage.js +617 -0
- package/lib/workers/verifier-worker.js +52 -0
- package/package.json +123 -0
- package/widget/forge-chat.js +789 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <forge-chat> — Reference chat widget (Web Component, vanilla JS, zero deps).
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* <script src="/widget/forge-chat.js"></script>
|
|
6
|
+
* <forge-chat endpoint="https://myapp.com/agent-api" theme="light"></forge-chat>
|
|
7
|
+
*
|
|
8
|
+
* Attributes:
|
|
9
|
+
* endpoint — Base URL for the agent API (required)
|
|
10
|
+
* theme — "light" or "dark" (default: "light")
|
|
11
|
+
* token — JWT token for auth (optional, can also be set via setToken())
|
|
12
|
+
* agent — Agent ID to route chat requests to a specific agent (optional)
|
|
13
|
+
*
|
|
14
|
+
* Custom events:
|
|
15
|
+
* forge:message — { detail: { role, content } }
|
|
16
|
+
* forge:tool-call — { detail: { tool, args } }
|
|
17
|
+
* forge:hitl — { detail: { tool, message, resumeToken } }
|
|
18
|
+
* forge:error — { detail: { message } }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
class ForgeChat extends HTMLElement {
|
|
22
|
+
constructor() {
|
|
23
|
+
super();
|
|
24
|
+
this._sessionId = null;
|
|
25
|
+
this._token = null;
|
|
26
|
+
this._messages = [];
|
|
27
|
+
this._prefsOpen = false;
|
|
28
|
+
this._abortCtrl = null;
|
|
29
|
+
this._streaming = false;
|
|
30
|
+
this._pendingTheme = null;
|
|
31
|
+
this.attachShadow({ mode: 'open' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static get observedAttributes() {
|
|
35
|
+
return ['endpoint', 'theme', 'token', 'agent'];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
connectedCallback() {
|
|
39
|
+
// Security: prefer setting this._token programmatically rather than via attribute
|
|
40
|
+
// to avoid token exposure in DOM/DevTools. Strip attribute after reading.
|
|
41
|
+
const tokenAttr = this.getAttribute('token');
|
|
42
|
+
if (tokenAttr) {
|
|
43
|
+
this._token = tokenAttr;
|
|
44
|
+
this.removeAttribute('token'); // strip from DOM to reduce exposure
|
|
45
|
+
}
|
|
46
|
+
this._render();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
disconnectedCallback() {
|
|
50
|
+
this._abortCtrl?.abort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
54
|
+
if (name === 'token') {
|
|
55
|
+
if (newVal !== null) { // Only update on set, not on remove
|
|
56
|
+
this._token = newVal;
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (name === 'theme') this._applyTheme(newVal);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setToken(token) {
|
|
64
|
+
this._token = token;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_render() {
|
|
68
|
+
const theme = this.getAttribute('theme') || 'light';
|
|
69
|
+
const isDark = theme === 'dark';
|
|
70
|
+
|
|
71
|
+
this.shadowRoot.innerHTML = `
|
|
72
|
+
<style>
|
|
73
|
+
:host { display: block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|
74
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
75
|
+
.forge-chat {
|
|
76
|
+
border: 1px solid ${isDark ? '#444' : '#ddd'};
|
|
77
|
+
border-radius: 8px;
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
height: 400px;
|
|
81
|
+
background: ${isDark ? '#1a1a2e' : '#fff'};
|
|
82
|
+
color: ${isDark ? '#e0e0e0' : '#333'};
|
|
83
|
+
position: relative;
|
|
84
|
+
}
|
|
85
|
+
.forge-header {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: space-between;
|
|
89
|
+
padding: 6px 12px;
|
|
90
|
+
border-bottom: 1px solid ${isDark ? '#333' : '#eee'};
|
|
91
|
+
font-size: 0.85em;
|
|
92
|
+
font-weight: 600;
|
|
93
|
+
color: ${isDark ? '#aaa' : '#666'};
|
|
94
|
+
}
|
|
95
|
+
.forge-header button {
|
|
96
|
+
background: none;
|
|
97
|
+
border: none;
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
font-size: 1.1em;
|
|
100
|
+
color: ${isDark ? '#aaa' : '#666'};
|
|
101
|
+
padding: 2px 6px;
|
|
102
|
+
border-radius: 4px;
|
|
103
|
+
}
|
|
104
|
+
.forge-header button:hover { background: ${isDark ? '#333' : '#eee'}; }
|
|
105
|
+
.forge-header button:focus-visible { outline: 2px solid #1976d2; outline-offset: 1px; }
|
|
106
|
+
.forge-messages {
|
|
107
|
+
flex: 1;
|
|
108
|
+
overflow-y: auto;
|
|
109
|
+
padding: 12px;
|
|
110
|
+
}
|
|
111
|
+
.forge-msg {
|
|
112
|
+
margin-bottom: 8px;
|
|
113
|
+
padding: 8px 12px;
|
|
114
|
+
border-radius: 6px;
|
|
115
|
+
max-width: 80%;
|
|
116
|
+
word-wrap: break-word;
|
|
117
|
+
line-height: 1.5;
|
|
118
|
+
}
|
|
119
|
+
.forge-msg.user {
|
|
120
|
+
background: ${isDark ? '#0d47a1' : '#e3f2fd'};
|
|
121
|
+
margin-left: auto;
|
|
122
|
+
text-align: right;
|
|
123
|
+
}
|
|
124
|
+
.forge-msg.assistant {
|
|
125
|
+
background: ${isDark ? '#2d2d44' : '#f5f5f5'};
|
|
126
|
+
}
|
|
127
|
+
/* Markdown styles inside assistant messages */
|
|
128
|
+
.forge-msg.assistant p { margin: 4px 0; }
|
|
129
|
+
.forge-msg.assistant code {
|
|
130
|
+
background: ${isDark ? '#1a1a2e' : '#e8e8e8'};
|
|
131
|
+
padding: 1px 4px;
|
|
132
|
+
border-radius: 3px;
|
|
133
|
+
font-size: 0.9em;
|
|
134
|
+
}
|
|
135
|
+
.forge-msg.assistant pre {
|
|
136
|
+
background: ${isDark ? '#111' : '#e8e8e8'};
|
|
137
|
+
padding: 8px;
|
|
138
|
+
border-radius: 4px;
|
|
139
|
+
overflow-x: auto;
|
|
140
|
+
font-size: 0.85em;
|
|
141
|
+
}
|
|
142
|
+
.forge-msg.assistant pre code { background: none; padding: 0; }
|
|
143
|
+
.forge-msg.assistant ul, .forge-msg.assistant ol { padding-left: 20px; margin: 4px 0; }
|
|
144
|
+
.forge-msg.assistant strong { font-weight: 600; }
|
|
145
|
+
.forge-msg.assistant em { font-style: italic; }
|
|
146
|
+
.forge-msg.assistant a { color: #1976d2; text-decoration: underline; }
|
|
147
|
+
.forge-msg.tool-warning {
|
|
148
|
+
background: ${isDark ? '#4a3800' : '#fff3e0'};
|
|
149
|
+
border-left: 3px solid #ff9800;
|
|
150
|
+
font-size: 0.9em;
|
|
151
|
+
}
|
|
152
|
+
.forge-msg.hitl {
|
|
153
|
+
background: ${isDark ? '#4a0000' : '#fce4ec'};
|
|
154
|
+
border-left: 3px solid #f44336;
|
|
155
|
+
text-align: center;
|
|
156
|
+
}
|
|
157
|
+
.forge-msg.hitl button {
|
|
158
|
+
margin: 4px;
|
|
159
|
+
padding: 6px 16px;
|
|
160
|
+
border-radius: 4px;
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
border: none;
|
|
163
|
+
font-size: 0.9em;
|
|
164
|
+
}
|
|
165
|
+
.forge-msg.hitl button:focus-visible { outline: 2px solid #1976d2; outline-offset: 1px; }
|
|
166
|
+
.forge-msg.hitl button.confirm { background: #4caf50; color: #fff; }
|
|
167
|
+
.forge-msg.hitl button.cancel { background: #f44336; color: #fff; }
|
|
168
|
+
.forge-typing {
|
|
169
|
+
font-style: italic;
|
|
170
|
+
color: ${isDark ? '#888' : '#999'};
|
|
171
|
+
padding: 4px 12px;
|
|
172
|
+
font-size: 0.9em;
|
|
173
|
+
}
|
|
174
|
+
.forge-typing .dots::after {
|
|
175
|
+
content: '';
|
|
176
|
+
animation: forge-dots 1.5s steps(4, end) infinite;
|
|
177
|
+
}
|
|
178
|
+
@keyframes forge-dots {
|
|
179
|
+
0% { content: ''; }
|
|
180
|
+
25% { content: '.'; }
|
|
181
|
+
50% { content: '..'; }
|
|
182
|
+
75% { content: '...'; }
|
|
183
|
+
}
|
|
184
|
+
.forge-input-row {
|
|
185
|
+
display: flex;
|
|
186
|
+
border-top: 1px solid ${isDark ? '#444' : '#ddd'};
|
|
187
|
+
padding: 8px;
|
|
188
|
+
}
|
|
189
|
+
.forge-input-row input {
|
|
190
|
+
flex: 1;
|
|
191
|
+
padding: 8px;
|
|
192
|
+
border: 1px solid ${isDark ? '#555' : '#ccc'};
|
|
193
|
+
border-radius: 4px;
|
|
194
|
+
background: ${isDark ? '#2d2d44' : '#fff'};
|
|
195
|
+
color: ${isDark ? '#e0e0e0' : '#333'};
|
|
196
|
+
outline: none;
|
|
197
|
+
font-size: 0.95em;
|
|
198
|
+
}
|
|
199
|
+
.forge-input-row input:focus-visible { border-color: #1976d2; box-shadow: 0 0 0 1px #1976d2; }
|
|
200
|
+
.forge-input-row button {
|
|
201
|
+
margin-left: 8px;
|
|
202
|
+
padding: 8px 16px;
|
|
203
|
+
border: none;
|
|
204
|
+
border-radius: 4px;
|
|
205
|
+
background: #1976d2;
|
|
206
|
+
color: #fff;
|
|
207
|
+
cursor: pointer;
|
|
208
|
+
font-size: 0.95em;
|
|
209
|
+
}
|
|
210
|
+
.forge-input-row button:focus-visible { outline: 2px solid #1976d2; outline-offset: 2px; }
|
|
211
|
+
.forge-input-row button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
212
|
+
|
|
213
|
+
/* Preference panel */
|
|
214
|
+
.forge-prefs-overlay {
|
|
215
|
+
position: absolute;
|
|
216
|
+
inset: 0;
|
|
217
|
+
background: ${isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)'};
|
|
218
|
+
display: flex;
|
|
219
|
+
align-items: center;
|
|
220
|
+
justify-content: center;
|
|
221
|
+
z-index: 10;
|
|
222
|
+
}
|
|
223
|
+
.forge-prefs-panel {
|
|
224
|
+
background: ${isDark ? '#2d2d44' : '#fff'};
|
|
225
|
+
border-radius: 8px;
|
|
226
|
+
padding: 16px;
|
|
227
|
+
width: 280px;
|
|
228
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
|
229
|
+
}
|
|
230
|
+
.forge-prefs-panel h3 { margin: 0 0 12px; font-size: 1em; }
|
|
231
|
+
.forge-prefs-panel label { display: block; margin-bottom: 8px; font-size: 0.9em; }
|
|
232
|
+
.forge-prefs-panel select, .forge-prefs-panel input[type="text"] {
|
|
233
|
+
width: 100%;
|
|
234
|
+
padding: 6px;
|
|
235
|
+
border: 1px solid ${isDark ? '#555' : '#ccc'};
|
|
236
|
+
border-radius: 4px;
|
|
237
|
+
background: ${isDark ? '#1a1a2e' : '#fff'};
|
|
238
|
+
color: ${isDark ? '#e0e0e0' : '#333'};
|
|
239
|
+
font-size: 0.9em;
|
|
240
|
+
margin-top: 2px;
|
|
241
|
+
}
|
|
242
|
+
.forge-prefs-panel select:focus-visible, .forge-prefs-panel input:focus-visible {
|
|
243
|
+
outline: 2px solid #1976d2;
|
|
244
|
+
}
|
|
245
|
+
.forge-prefs-panel .prefs-actions {
|
|
246
|
+
display: flex;
|
|
247
|
+
gap: 8px;
|
|
248
|
+
margin-top: 12px;
|
|
249
|
+
justify-content: flex-end;
|
|
250
|
+
}
|
|
251
|
+
.forge-prefs-panel .prefs-actions button {
|
|
252
|
+
padding: 6px 14px;
|
|
253
|
+
border: none;
|
|
254
|
+
border-radius: 4px;
|
|
255
|
+
cursor: pointer;
|
|
256
|
+
font-size: 0.9em;
|
|
257
|
+
}
|
|
258
|
+
.forge-prefs-panel .prefs-actions button:focus-visible { outline: 2px solid #1976d2; outline-offset: 1px; }
|
|
259
|
+
.forge-prefs-panel .prefs-actions .save-btn { background: #1976d2; color: #fff; }
|
|
260
|
+
.forge-prefs-panel .prefs-actions .cancel-btn {
|
|
261
|
+
background: ${isDark ? '#444' : '#e0e0e0'};
|
|
262
|
+
color: ${isDark ? '#ccc' : '#333'};
|
|
263
|
+
}
|
|
264
|
+
.forge-prefs-panel .perm-note { font-size: 0.8em; color: ${isDark ? '#888' : '#999'}; margin-top: 2px; }
|
|
265
|
+
|
|
266
|
+
/* Screen reader only */
|
|
267
|
+
.sr-only {
|
|
268
|
+
position: absolute;
|
|
269
|
+
width: 1px; height: 1px;
|
|
270
|
+
padding: 0; margin: -1px;
|
|
271
|
+
overflow: hidden;
|
|
272
|
+
clip: rect(0,0,0,0);
|
|
273
|
+
border: 0;
|
|
274
|
+
}
|
|
275
|
+
</style>
|
|
276
|
+
<div class="forge-chat" role="region" aria-label="Chat">
|
|
277
|
+
<div class="forge-header">
|
|
278
|
+
<span>${this.getAttribute('agent') ? `Forge Chat — ${this._escapeHtml(this.getAttribute('agent'))}` : 'Forge Chat'}</span>
|
|
279
|
+
<button id="prefs-btn" aria-label="Open preferences" title="Preferences">⚙</button>
|
|
280
|
+
</div>
|
|
281
|
+
<div class="forge-messages" id="messages" role="log" aria-live="polite" aria-label="Chat messages"></div>
|
|
282
|
+
<div id="typing" class="forge-typing" style="display:none" aria-live="polite">
|
|
283
|
+
<span>Assistant is thinking</span><span class="dots"></span>
|
|
284
|
+
</div>
|
|
285
|
+
<div class="forge-input-row">
|
|
286
|
+
<label for="forge-input" class="sr-only">Message</label>
|
|
287
|
+
<input type="text" id="forge-input" placeholder="Type a message..." autocomplete="off" aria-label="Type a message" />
|
|
288
|
+
<button id="send" aria-label="Send message">Send</button>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
`;
|
|
292
|
+
|
|
293
|
+
const input = this.shadowRoot.getElementById('forge-input');
|
|
294
|
+
const sendBtn = this.shadowRoot.getElementById('send');
|
|
295
|
+
const prefsBtn = this.shadowRoot.getElementById('prefs-btn');
|
|
296
|
+
|
|
297
|
+
sendBtn.addEventListener('click', () => this._sendMessage());
|
|
298
|
+
input.addEventListener('keydown', (e) => {
|
|
299
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
this._sendMessage();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
prefsBtn.addEventListener('click', () => this._togglePrefs());
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
_addMessage(role, content, extraClass = '') {
|
|
308
|
+
const msgDiv = document.createElement('div');
|
|
309
|
+
msgDiv.className = `forge-msg ${role} ${extraClass}`.trim();
|
|
310
|
+
msgDiv.setAttribute('role', role === 'user' ? 'status' : 'article');
|
|
311
|
+
|
|
312
|
+
if (role === 'assistant' && !extraClass) {
|
|
313
|
+
msgDiv.innerHTML = this._renderMarkdown(content);
|
|
314
|
+
} else {
|
|
315
|
+
msgDiv.textContent = content;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const container = this.shadowRoot.getElementById('messages');
|
|
319
|
+
container.appendChild(msgDiv);
|
|
320
|
+
container.scrollTop = container.scrollHeight;
|
|
321
|
+
return msgDiv;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Basic markdown renderer — handles the most common patterns:
|
|
326
|
+
* code blocks, inline code, bold, italic, links, lists, paragraphs.
|
|
327
|
+
* No external dependencies.
|
|
328
|
+
*/
|
|
329
|
+
_renderMarkdown(text) {
|
|
330
|
+
if (!text) return '';
|
|
331
|
+
|
|
332
|
+
// Escape HTML
|
|
333
|
+
let html = text
|
|
334
|
+
.replace(/&/g, '&')
|
|
335
|
+
.replace(/</g, '<')
|
|
336
|
+
.replace(/>/g, '>');
|
|
337
|
+
|
|
338
|
+
// Code blocks: ```lang\n...\n```
|
|
339
|
+
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
|
340
|
+
return `<pre><code>${code.trim()}</code></pre>`;
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Inline code: `code`
|
|
344
|
+
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
345
|
+
|
|
346
|
+
// Bold: **text** or __text__
|
|
347
|
+
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
348
|
+
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
|
349
|
+
|
|
350
|
+
// Italic: *text* or _text_ (but not inside words)
|
|
351
|
+
html = html.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, '<em>$1</em>');
|
|
352
|
+
html = html.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, '<em>$1</em>');
|
|
353
|
+
|
|
354
|
+
// Links: [text](url) — reject javascript: and data: protocols
|
|
355
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, href) => {
|
|
356
|
+
const trimmed = href.trim().toLowerCase();
|
|
357
|
+
if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:') || trimmed.startsWith('vbscript:')) {
|
|
358
|
+
return text; // strip the link, keep text
|
|
359
|
+
}
|
|
360
|
+
const safeHref = href.replace(/"/g, '"');
|
|
361
|
+
return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Unordered lists: lines starting with - or *
|
|
365
|
+
html = html.replace(/^([ \t]*[-*] .+(?:\n|$))+/gm, (match) => {
|
|
366
|
+
const items = match.trim().split('\n').map(line => {
|
|
367
|
+
return `<li>${line.replace(/^[ \t]*[-*] /, '')}</li>`;
|
|
368
|
+
}).join('');
|
|
369
|
+
return `<ul>${items}</ul>`;
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Ordered lists: lines starting with 1. 2. etc
|
|
373
|
+
html = html.replace(/^([ \t]*\d+\. .+(?:\n|$))+/gm, (match) => {
|
|
374
|
+
const items = match.trim().split('\n').map(line => {
|
|
375
|
+
return `<li>${line.replace(/^[ \t]*\d+\. /, '')}</li>`;
|
|
376
|
+
}).join('');
|
|
377
|
+
return `<ol>${items}</ol>`;
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Paragraphs: double newlines
|
|
381
|
+
html = html.replace(/\n\n+/g, '</p><p>');
|
|
382
|
+
if (!html.startsWith('<')) html = `<p>${html}`;
|
|
383
|
+
if (!html.endsWith('>')) html = `${html}</p>`;
|
|
384
|
+
|
|
385
|
+
// Single newlines → <br> (but not inside pre/code blocks)
|
|
386
|
+
html = html.replace(/(?<!<\/li>|<\/ul>|<\/ol>|<\/pre>|<\/code>)\n(?!<)/g, '<br>');
|
|
387
|
+
|
|
388
|
+
return html;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
_addStreamingMessage() {
|
|
392
|
+
const msgDiv = document.createElement('div');
|
|
393
|
+
msgDiv.className = 'forge-msg assistant';
|
|
394
|
+
msgDiv.setAttribute('role', 'article');
|
|
395
|
+
const container = this.shadowRoot.getElementById('messages');
|
|
396
|
+
container.appendChild(msgDiv);
|
|
397
|
+
container.scrollTop = container.scrollHeight;
|
|
398
|
+
return msgDiv;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
_updateStreamingMessage(msgDiv, text) {
|
|
402
|
+
msgDiv.textContent = text;
|
|
403
|
+
const container = this.shadowRoot.getElementById('messages');
|
|
404
|
+
container.scrollTop = container.scrollHeight;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
_finalizeStreamingMessage(msgDiv, text) {
|
|
408
|
+
msgDiv.innerHTML = this._renderMarkdown(text);
|
|
409
|
+
const container = this.shadowRoot.getElementById('messages');
|
|
410
|
+
container.scrollTop = container.scrollHeight;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
_showTyping() {
|
|
414
|
+
const el = this.shadowRoot.getElementById('typing');
|
|
415
|
+
if (el) el.style.display = 'block';
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
_hideTyping() {
|
|
419
|
+
const el = this.shadowRoot.getElementById('typing');
|
|
420
|
+
if (el) el.style.display = 'none';
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async _readSseStream(reader) {
|
|
424
|
+
const decoder = new TextDecoder();
|
|
425
|
+
let buffer = '';
|
|
426
|
+
let assistantText = '';
|
|
427
|
+
let assistantMsgDiv = null;
|
|
428
|
+
|
|
429
|
+
while (true) {
|
|
430
|
+
const { done, value } = await reader.read();
|
|
431
|
+
if (done) break;
|
|
432
|
+
|
|
433
|
+
buffer += decoder.decode(value, { stream: true });
|
|
434
|
+
const lines = buffer.split('\n');
|
|
435
|
+
buffer = lines.pop(); // keep incomplete line
|
|
436
|
+
|
|
437
|
+
let eventType = null;
|
|
438
|
+
for (const line of lines) {
|
|
439
|
+
if (line.startsWith('event: ')) {
|
|
440
|
+
eventType = line.slice(7).trim();
|
|
441
|
+
} else if (line.startsWith('data: ') && eventType) {
|
|
442
|
+
try {
|
|
443
|
+
const data = JSON.parse(line.slice(6));
|
|
444
|
+
|
|
445
|
+
if (eventType === 'text_delta') {
|
|
446
|
+
assistantText += data.content || '';
|
|
447
|
+
if (!assistantMsgDiv) {
|
|
448
|
+
this._hideTyping();
|
|
449
|
+
assistantMsgDiv = this._addStreamingMessage();
|
|
450
|
+
}
|
|
451
|
+
this._updateStreamingMessage(assistantMsgDiv, assistantText);
|
|
452
|
+
} else if (eventType === 'text') {
|
|
453
|
+
assistantText = data.content || '';
|
|
454
|
+
if (assistantMsgDiv) {
|
|
455
|
+
this._finalizeStreamingMessage(assistantMsgDiv, assistantText);
|
|
456
|
+
}
|
|
457
|
+
} else if (eventType === 'session') {
|
|
458
|
+
this._sessionId = data.sessionId;
|
|
459
|
+
} else {
|
|
460
|
+
this._handleSSEEvent(eventType, data);
|
|
461
|
+
}
|
|
462
|
+
} catch { /* skip malformed */ }
|
|
463
|
+
eventType = null; // reset after each complete event
|
|
464
|
+
} else if (line === '') {
|
|
465
|
+
// blank line = SSE message boundary; reset eventType for next message
|
|
466
|
+
eventType = null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return { assistantText, assistantMsgDiv };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async _sendMessage() {
|
|
475
|
+
const input = this.shadowRoot.getElementById('forge-input');
|
|
476
|
+
const message = input.value.trim();
|
|
477
|
+
if (!message) return;
|
|
478
|
+
|
|
479
|
+
input.value = '';
|
|
480
|
+
this._addMessage('user', message);
|
|
481
|
+
this.dispatchEvent(new CustomEvent('forge:message', { detail: { role: 'user', content: message } }));
|
|
482
|
+
|
|
483
|
+
const endpoint = this.getAttribute('endpoint');
|
|
484
|
+
if (!endpoint) {
|
|
485
|
+
this._addMessage('assistant', 'Error: no endpoint configured');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const sendBtn = this.shadowRoot.getElementById('send');
|
|
490
|
+
sendBtn.disabled = true;
|
|
491
|
+
this._showTyping();
|
|
492
|
+
this._streaming = true;
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
496
|
+
if (this._token) headers['Authorization'] = `Bearer ${this._token}`;
|
|
497
|
+
|
|
498
|
+
const chatBody = { message, sessionId: this._sessionId };
|
|
499
|
+
const agentAttr = this.getAttribute('agent');
|
|
500
|
+
if (agentAttr) chatBody.agentId = agentAttr;
|
|
501
|
+
|
|
502
|
+
this._abortCtrl = new AbortController();
|
|
503
|
+
const res = await fetch(`${endpoint}/chat`, {
|
|
504
|
+
method: 'POST',
|
|
505
|
+
headers,
|
|
506
|
+
body: JSON.stringify(chatBody),
|
|
507
|
+
signal: this._abortCtrl.signal
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
if (!res.ok) {
|
|
511
|
+
const err = await res.text();
|
|
512
|
+
this._hideTyping();
|
|
513
|
+
this._addMessage('assistant', `Error: ${res.status} — ${err}`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Parse SSE stream via shared helper
|
|
518
|
+
const reader = res.body.getReader();
|
|
519
|
+
const { assistantText, assistantMsgDiv } = await this._readSseStream(reader);
|
|
520
|
+
|
|
521
|
+
this._hideTyping();
|
|
522
|
+
|
|
523
|
+
// Non-streaming fallback: if no streaming div was created, render traditionally
|
|
524
|
+
if (assistantText && !assistantMsgDiv) {
|
|
525
|
+
this._addMessage('assistant', assistantText);
|
|
526
|
+
}
|
|
527
|
+
if (assistantText) {
|
|
528
|
+
this.dispatchEvent(new CustomEvent('forge:message', { detail: { role: 'assistant', content: assistantText } }));
|
|
529
|
+
}
|
|
530
|
+
} catch (err) {
|
|
531
|
+
this._hideTyping();
|
|
532
|
+
this._addMessage('assistant', `Connection error: ${err.message}`);
|
|
533
|
+
this.dispatchEvent(new CustomEvent('forge:error', { detail: { message: err.message } }));
|
|
534
|
+
} finally {
|
|
535
|
+
this._streaming = false;
|
|
536
|
+
if (this._pendingTheme !== null) {
|
|
537
|
+
this._applyTheme(this._pendingTheme);
|
|
538
|
+
this._pendingTheme = null;
|
|
539
|
+
}
|
|
540
|
+
sendBtn.disabled = false;
|
|
541
|
+
input.focus();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
_handleSSEEvent(type, data) {
|
|
546
|
+
switch (type) {
|
|
547
|
+
case 'tool_call':
|
|
548
|
+
this.dispatchEvent(new CustomEvent('forge:tool-call', { detail: data }));
|
|
549
|
+
break;
|
|
550
|
+
case 'tool_warning':
|
|
551
|
+
this._addMessage('assistant', `Warning: ${data.message}`, 'tool-warning');
|
|
552
|
+
break;
|
|
553
|
+
case 'hitl':
|
|
554
|
+
this._showHitlDialog(data);
|
|
555
|
+
this.dispatchEvent(new CustomEvent('forge:hitl', { detail: data }));
|
|
556
|
+
break;
|
|
557
|
+
case 'error':
|
|
558
|
+
this._hideTyping();
|
|
559
|
+
this._addMessage('assistant', `Error: ${data.message}`);
|
|
560
|
+
this.dispatchEvent(new CustomEvent('forge:error', { detail: data }));
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
_showHitlDialog(data) {
|
|
566
|
+
this._hideTyping();
|
|
567
|
+
const msgDiv = document.createElement('div');
|
|
568
|
+
msgDiv.className = 'forge-msg hitl';
|
|
569
|
+
msgDiv.setAttribute('role', 'alertdialog');
|
|
570
|
+
msgDiv.setAttribute('aria-label', 'Tool confirmation required');
|
|
571
|
+
msgDiv.innerHTML = `
|
|
572
|
+
<p>${this._escapeHtml(data.message || 'Tool call requires confirmation')}</p>
|
|
573
|
+
<p><strong>${this._escapeHtml(data.tool || 'Unknown tool')}</strong></p>
|
|
574
|
+
<button class="confirm" aria-label="Confirm tool call">Confirm</button>
|
|
575
|
+
<button class="cancel" aria-label="Cancel tool call">Cancel</button>
|
|
576
|
+
`;
|
|
577
|
+
|
|
578
|
+
const container = this.shadowRoot.getElementById('messages');
|
|
579
|
+
container.appendChild(msgDiv);
|
|
580
|
+
container.scrollTop = container.scrollHeight;
|
|
581
|
+
|
|
582
|
+
// Focus the confirm button for keyboard users
|
|
583
|
+
const confirmBtn = msgDiv.querySelector('.confirm');
|
|
584
|
+
confirmBtn.focus();
|
|
585
|
+
|
|
586
|
+
confirmBtn.addEventListener('click', () => {
|
|
587
|
+
this._resumeHitl(data.resumeToken, true);
|
|
588
|
+
msgDiv.remove();
|
|
589
|
+
});
|
|
590
|
+
msgDiv.querySelector('.cancel').addEventListener('click', () => {
|
|
591
|
+
this._resumeHitl(data.resumeToken, false);
|
|
592
|
+
msgDiv.remove();
|
|
593
|
+
this._addMessage('assistant', 'Action cancelled.');
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async _resumeHitl(resumeToken, confirmed) {
|
|
598
|
+
const endpoint = this.getAttribute('endpoint');
|
|
599
|
+
if (!endpoint || !resumeToken) return;
|
|
600
|
+
|
|
601
|
+
this._streaming = true;
|
|
602
|
+
this._abortCtrl = new AbortController();
|
|
603
|
+
|
|
604
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
605
|
+
if (this._token) headers['Authorization'] = `Bearer ${this._token}`;
|
|
606
|
+
|
|
607
|
+
if (confirmed) this._showTyping();
|
|
608
|
+
|
|
609
|
+
try {
|
|
610
|
+
const res = await fetch(`${endpoint}/chat/resume`, {
|
|
611
|
+
method: 'POST',
|
|
612
|
+
headers,
|
|
613
|
+
body: JSON.stringify({ resumeToken, confirmed }),
|
|
614
|
+
signal: this._abortCtrl.signal
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// Read the SSE response — resumed agent continues streaming
|
|
618
|
+
if (res.ok && res.body) {
|
|
619
|
+
const reader = res.body.getReader();
|
|
620
|
+
const { assistantText } = await this._readSseStream(reader);
|
|
621
|
+
this._hideTyping();
|
|
622
|
+
if (assistantText) {
|
|
623
|
+
this.dispatchEvent(new CustomEvent('forge:message', { detail: { role: 'assistant', content: assistantText } }));
|
|
624
|
+
}
|
|
625
|
+
} else {
|
|
626
|
+
this._hideTyping();
|
|
627
|
+
}
|
|
628
|
+
} catch (err) {
|
|
629
|
+
this._hideTyping();
|
|
630
|
+
this.dispatchEvent(new CustomEvent('forge:error', { detail: { message: err.message } }));
|
|
631
|
+
} finally {
|
|
632
|
+
this._streaming = false;
|
|
633
|
+
if (this._pendingTheme !== null) {
|
|
634
|
+
this._applyTheme(this._pendingTheme);
|
|
635
|
+
this._pendingTheme = null;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ── Preference panel ───────────────────────────────────────────────────
|
|
641
|
+
|
|
642
|
+
async _togglePrefs() {
|
|
643
|
+
if (this._prefsOpen) {
|
|
644
|
+
this._closePrefs();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const endpoint = this.getAttribute('endpoint');
|
|
649
|
+
if (!endpoint) return;
|
|
650
|
+
|
|
651
|
+
// Fetch current preferences
|
|
652
|
+
const headers = {};
|
|
653
|
+
if (this._token) headers['Authorization'] = `Bearer ${this._token}`;
|
|
654
|
+
|
|
655
|
+
let prefsData;
|
|
656
|
+
try {
|
|
657
|
+
const res = await fetch(`${endpoint}/user/preferences`, { headers });
|
|
658
|
+
if (!res.ok) {
|
|
659
|
+
if (res.status === 401) return; // Not authenticated
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
prefsData = await res.json();
|
|
663
|
+
} catch {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
this._prefsOpen = true;
|
|
668
|
+
const chat = this.shadowRoot.querySelector('.forge-chat');
|
|
669
|
+
|
|
670
|
+
const overlay = document.createElement('div');
|
|
671
|
+
overlay.className = 'forge-prefs-overlay';
|
|
672
|
+
overlay.id = 'prefs-overlay';
|
|
673
|
+
overlay.setAttribute('role', 'dialog');
|
|
674
|
+
overlay.setAttribute('aria-label', 'User preferences');
|
|
675
|
+
|
|
676
|
+
const canModel = prefsData.permissions?.canChangeModel;
|
|
677
|
+
const canHitl = prefsData.permissions?.canChangeHitl;
|
|
678
|
+
const hitlLevels = prefsData.options?.hitlLevels || ['autonomous', 'cautious', 'standard', 'paranoid'];
|
|
679
|
+
const currentModel = prefsData.preferences?.model || '';
|
|
680
|
+
const currentHitl = prefsData.preferences?.hitlLevel || prefsData.effective?.hitlLevel || 'cautious';
|
|
681
|
+
|
|
682
|
+
overlay.innerHTML = `
|
|
683
|
+
<div class="forge-prefs-panel">
|
|
684
|
+
<h3>Preferences</h3>
|
|
685
|
+
${canModel ? `
|
|
686
|
+
<label>
|
|
687
|
+
Model
|
|
688
|
+
<input type="text" id="pref-model" value="${this._escapeHtml(currentModel)}" placeholder="${this._escapeHtml(prefsData.effective?.model || 'default')}" />
|
|
689
|
+
</label>
|
|
690
|
+
` : `<p class="perm-note">Model selection disabled by admin</p>`}
|
|
691
|
+
${canHitl ? `
|
|
692
|
+
<label>
|
|
693
|
+
Confirmation level
|
|
694
|
+
<select id="pref-hitl">
|
|
695
|
+
${hitlLevels.map(l => {
|
|
696
|
+
const safe = this._escapeHtml(l);
|
|
697
|
+
return `<option value="${safe}" ${l === currentHitl ? 'selected' : ''}>${safe}</option>`;
|
|
698
|
+
}).join('')}
|
|
699
|
+
</select>
|
|
700
|
+
</label>
|
|
701
|
+
` : `<p class="perm-note">HITL level disabled by admin</p>`}
|
|
702
|
+
<p class="perm-note">Effective: ${this._escapeHtml(prefsData.effective?.model || 'default')} / ${this._escapeHtml(prefsData.effective?.hitlLevel || 'default')}</p>
|
|
703
|
+
<div class="prefs-actions">
|
|
704
|
+
<button class="cancel-btn" id="prefs-cancel">Cancel</button>
|
|
705
|
+
${canModel || canHitl ? '<button class="save-btn" id="prefs-save">Save</button>' : ''}
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
`;
|
|
709
|
+
|
|
710
|
+
chat.appendChild(overlay);
|
|
711
|
+
|
|
712
|
+
// Focus first interactive element
|
|
713
|
+
const firstInput = overlay.querySelector('input, select, button');
|
|
714
|
+
if (firstInput) firstInput.focus();
|
|
715
|
+
|
|
716
|
+
overlay.querySelector('#prefs-cancel').addEventListener('click', () => this._closePrefs());
|
|
717
|
+
|
|
718
|
+
const saveBtn = overlay.querySelector('#prefs-save');
|
|
719
|
+
if (saveBtn) {
|
|
720
|
+
saveBtn.addEventListener('click', () => this._savePrefs(prefsData.permissions));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Close on Escape
|
|
724
|
+
overlay.addEventListener('keydown', (e) => {
|
|
725
|
+
if (e.key === 'Escape') this._closePrefs();
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async _savePrefs(permissions) {
|
|
730
|
+
const endpoint = this.getAttribute('endpoint');
|
|
731
|
+
if (!endpoint) return;
|
|
732
|
+
|
|
733
|
+
const body = {};
|
|
734
|
+
if (permissions.canChangeModel) {
|
|
735
|
+
const modelInput = this.shadowRoot.getElementById('pref-model');
|
|
736
|
+
if (modelInput?.value) body.model = modelInput.value;
|
|
737
|
+
}
|
|
738
|
+
if (permissions.canChangeHitl) {
|
|
739
|
+
const hitlSelect = this.shadowRoot.getElementById('pref-hitl');
|
|
740
|
+
if (hitlSelect?.value) body.hitl_level = hitlSelect.value;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (Object.keys(body).length === 0) {
|
|
744
|
+
this._closePrefs();
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
749
|
+
if (this._token) headers['Authorization'] = `Bearer ${this._token}`;
|
|
750
|
+
|
|
751
|
+
try {
|
|
752
|
+
await fetch(`${endpoint}/user/preferences`, {
|
|
753
|
+
method: 'PUT',
|
|
754
|
+
headers,
|
|
755
|
+
body: JSON.stringify(body)
|
|
756
|
+
});
|
|
757
|
+
} catch { /* ignore save error */ }
|
|
758
|
+
|
|
759
|
+
this._closePrefs();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
_closePrefs() {
|
|
763
|
+
this._prefsOpen = false;
|
|
764
|
+
const overlay = this.shadowRoot.getElementById('prefs-overlay');
|
|
765
|
+
if (overlay) overlay.remove();
|
|
766
|
+
// Return focus to prefs button
|
|
767
|
+
const prefsBtn = this.shadowRoot.getElementById('prefs-btn');
|
|
768
|
+
if (prefsBtn) prefsBtn.focus();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
_escapeHtml(str) {
|
|
772
|
+
return String(str)
|
|
773
|
+
.replace(/&/g, '&')
|
|
774
|
+
.replace(/</g, '<')
|
|
775
|
+
.replace(/>/g, '>')
|
|
776
|
+
.replace(/"/g, '"');
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
_applyTheme(theme) {
|
|
780
|
+
if (!this.isConnected) return;
|
|
781
|
+
if (this._streaming) {
|
|
782
|
+
this._pendingTheme = theme; // queue it — apply after stream completes
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
this._render();
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
customElements.define('forge-chat', ForgeChat);
|