@versdotsh/reef 0.1.5 → 0.1.6
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/.github/workflows/publish.yml +45 -0
- package/.github/workflows/test.yml +1 -26
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/services/board/index.ts +0 -155
- package/services/board/routes.ts +0 -335
- package/services/board/store.ts +0 -329
- package/services/board/tools.ts +0 -214
- package/services/feed/behaviors.ts +0 -23
- package/services/feed/index.ts +0 -117
- package/services/feed/routes.ts +0 -224
- package/services/feed/store.ts +0 -194
- package/services/feed/tools.ts +0 -83
- package/services/ui/auth.ts +0 -61
- package/services/ui/index.ts +0 -16
- package/services/ui/routes.ts +0 -160
- package/services/ui/static/app.js +0 -369
- package/services/ui/static/index.html +0 -42
- package/services/ui/static/style.css +0 -157
package/services/ui/routes.ts
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* UI routes — serves the dashboard, handles magic link auth, proxies API calls.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { Hono } from "hono";
|
|
6
|
-
import { createMagicLink, consumeMagicLink, createSession, validateSession } from "./auth.js";
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
|
|
10
|
-
const AUTH_TOKEN = process.env.VERS_AUTH_TOKEN || "test-token";
|
|
11
|
-
|
|
12
|
-
function getStaticDir(): string {
|
|
13
|
-
return join(import.meta.dir, "static");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function getSessionId(c: any): string | undefined {
|
|
17
|
-
const cookie = c.req.header("cookie") || "";
|
|
18
|
-
const match = cookie.match(/(?:^|;\s*)session=([^;]+)/);
|
|
19
|
-
return match?.[1];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function hasBearerAuth(c: any): boolean {
|
|
23
|
-
const auth = c.req.header("authorization") || "";
|
|
24
|
-
return auth === `Bearer ${AUTH_TOKEN}`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function createRoutes(): Hono {
|
|
28
|
-
const routes = new Hono();
|
|
29
|
-
|
|
30
|
-
// --- Auth ---
|
|
31
|
-
|
|
32
|
-
// Generate magic link (requires bearer auth)
|
|
33
|
-
routes.post("/auth/magic-link", (c) => {
|
|
34
|
-
if (!hasBearerAuth(c)) return c.json({ error: "Unauthorized" }, 401);
|
|
35
|
-
|
|
36
|
-
const link = createMagicLink();
|
|
37
|
-
const host = c.req.header("host") || "localhost:3000";
|
|
38
|
-
const proto = c.req.header("x-forwarded-proto") || "https";
|
|
39
|
-
const url = `${proto}://${host}/ui/login?token=${link.token}`;
|
|
40
|
-
return c.json({ url, expiresAt: link.expiresAt });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
// Login page / magic link consumer
|
|
44
|
-
routes.get("/ui/login", (c) => {
|
|
45
|
-
const token = c.req.query("token");
|
|
46
|
-
|
|
47
|
-
if (token) {
|
|
48
|
-
const valid = consumeMagicLink(token);
|
|
49
|
-
if (valid) {
|
|
50
|
-
const session = createSession();
|
|
51
|
-
return c.html(
|
|
52
|
-
`<html><head><meta http-equiv="refresh" content="0;url=/ui/"></head></html>`,
|
|
53
|
-
200,
|
|
54
|
-
{ "Set-Cookie": `session=${session.id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` },
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
return c.html(`
|
|
58
|
-
<html><body style="background:#0a0a0a;color:#f55;font-family:monospace;padding:2em">
|
|
59
|
-
<h2>Invalid or expired link</h2>
|
|
60
|
-
<p>Request a new magic link from the API.</p>
|
|
61
|
-
</body></html>
|
|
62
|
-
`, 401);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return c.html(`
|
|
66
|
-
<html><body style="background:#0a0a0a;color:#888;font-family:monospace;padding:2em">
|
|
67
|
-
<h2>Fleet Services Dashboard</h2>
|
|
68
|
-
<p>Access requires a magic link. Generate one via:</p>
|
|
69
|
-
<pre style="color:#4f9">POST /auth/magic-link</pre>
|
|
70
|
-
</body></html>
|
|
71
|
-
`);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
// --- Session-protected UI routes ---
|
|
75
|
-
|
|
76
|
-
routes.use("/ui/*", async (c, next) => {
|
|
77
|
-
const path = new URL(c.req.url).pathname;
|
|
78
|
-
if (path === "/ui/login" || path.startsWith("/ui/static/")) return next();
|
|
79
|
-
|
|
80
|
-
// In dev mode (no auth token set), skip session check
|
|
81
|
-
if (!process.env.VERS_AUTH_TOKEN) return next();
|
|
82
|
-
|
|
83
|
-
const sessionId = getSessionId(c);
|
|
84
|
-
if (!validateSession(sessionId)) return c.redirect("/ui/login");
|
|
85
|
-
return next();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// Dashboard
|
|
89
|
-
routes.get("/ui/", (c) => {
|
|
90
|
-
try {
|
|
91
|
-
const html = readFileSync(join(getStaticDir(), "index.html"), "utf-8");
|
|
92
|
-
return c.html(html);
|
|
93
|
-
} catch {
|
|
94
|
-
return c.text("Dashboard files not found", 500);
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// Static files
|
|
99
|
-
routes.get("/ui/static/:file", (c) => {
|
|
100
|
-
const file = c.req.param("file");
|
|
101
|
-
if (file.includes("..") || file.includes("/")) return c.text("Not found", 404);
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
const content = readFileSync(join(getStaticDir(), file), "utf-8");
|
|
105
|
-
const ext = file.split(".").pop();
|
|
106
|
-
const contentType =
|
|
107
|
-
ext === "css" ? "text/css" :
|
|
108
|
-
ext === "js" ? "application/javascript" :
|
|
109
|
-
"text/plain";
|
|
110
|
-
return c.body(content, 200, { "Content-Type": contentType });
|
|
111
|
-
} catch {
|
|
112
|
-
return c.text("Not found", 404);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// --- API proxy (injects bearer token so browser never needs it) ---
|
|
117
|
-
|
|
118
|
-
routes.all("/ui/api/*", async (c) => {
|
|
119
|
-
const url = new URL(c.req.url);
|
|
120
|
-
const apiPath = url.pathname.replace(/^\/ui\/api/, "");
|
|
121
|
-
const queryString = url.search;
|
|
122
|
-
|
|
123
|
-
const port = process.env.PORT || "3000";
|
|
124
|
-
const internalUrl = `http://127.0.0.1:${port}${apiPath}${queryString}`;
|
|
125
|
-
|
|
126
|
-
const headers: Record<string, string> = {
|
|
127
|
-
Authorization: `Bearer ${AUTH_TOKEN}`,
|
|
128
|
-
};
|
|
129
|
-
const contentType = c.req.header("content-type");
|
|
130
|
-
if (contentType) headers["Content-Type"] = contentType;
|
|
131
|
-
|
|
132
|
-
const method = c.req.method;
|
|
133
|
-
const body = method !== "GET" && method !== "HEAD" ? await c.req.text() : undefined;
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
const resp = await fetch(internalUrl, { method, headers, body });
|
|
137
|
-
|
|
138
|
-
// SSE passthrough
|
|
139
|
-
if (resp.headers.get("content-type")?.includes("text/event-stream")) {
|
|
140
|
-
return new Response(resp.body, {
|
|
141
|
-
status: resp.status,
|
|
142
|
-
headers: {
|
|
143
|
-
"Content-Type": "text/event-stream",
|
|
144
|
-
"Cache-Control": "no-cache",
|
|
145
|
-
Connection: "keep-alive",
|
|
146
|
-
},
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const text = await resp.text();
|
|
151
|
-
return c.body(text, resp.status as any, {
|
|
152
|
-
"Content-Type": resp.headers.get("content-type") || "application/json",
|
|
153
|
-
});
|
|
154
|
-
} catch (e) {
|
|
155
|
-
return c.json({ error: "Proxy error", details: String(e) }, 502);
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
return routes;
|
|
160
|
-
}
|
|
@@ -1,369 +0,0 @@
|
|
|
1
|
-
// =============================================================================
|
|
2
|
-
// reef UI — dynamic panel discovery + built-in chat
|
|
3
|
-
// =============================================================================
|
|
4
|
-
|
|
5
|
-
const API = PANEL_API; // set in index.html
|
|
6
|
-
|
|
7
|
-
function esc(s) {
|
|
8
|
-
const d = document.createElement('div');
|
|
9
|
-
d.textContent = s || '';
|
|
10
|
-
return d.innerHTML;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function timeAgo(iso) {
|
|
14
|
-
const ms = Date.now() - new Date(iso).getTime();
|
|
15
|
-
if (ms < 60000) return `${Math.floor(ms / 1000)}s ago`;
|
|
16
|
-
if (ms < 3600000) return `${Math.floor(ms / 60000)}m ago`;
|
|
17
|
-
if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`;
|
|
18
|
-
return `${Math.floor(ms / 86400000)}d ago`;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// =============================================================================
|
|
22
|
-
// Panel discovery
|
|
23
|
-
// =============================================================================
|
|
24
|
-
|
|
25
|
-
const tabsEl = document.getElementById('tabs');
|
|
26
|
-
const panelsEl = document.getElementById('panels');
|
|
27
|
-
const statusEl = document.getElementById('status');
|
|
28
|
-
let activeTab = null;
|
|
29
|
-
const loadedPanels = new Map(); // name → container element
|
|
30
|
-
|
|
31
|
-
async function discoverPanels() {
|
|
32
|
-
try {
|
|
33
|
-
// Get loaded services
|
|
34
|
-
const res = await fetch(`${API}/services`);
|
|
35
|
-
if (!res.ok) throw new Error(`${res.status}`);
|
|
36
|
-
const data = await res.json();
|
|
37
|
-
const services = data.modules || data.services || (Array.isArray(data) ? data : []);
|
|
38
|
-
|
|
39
|
-
// Try to fetch _panel for each service (skip ui itself)
|
|
40
|
-
const panelResults = await Promise.allSettled(
|
|
41
|
-
services
|
|
42
|
-
.filter(s => s.name !== 'ui')
|
|
43
|
-
.map(async (s) => {
|
|
44
|
-
const r = await fetch(`${API}/${s.name}/_panel`);
|
|
45
|
-
if (!r.ok) return null;
|
|
46
|
-
const ct = r.headers.get('content-type') || '';
|
|
47
|
-
if (!ct.includes('html')) return null;
|
|
48
|
-
return { name: s.name, html: await r.text() };
|
|
49
|
-
})
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
const panels = panelResults
|
|
53
|
-
.filter(r => r.status === 'fulfilled' && r.value)
|
|
54
|
-
.map(r => r.value);
|
|
55
|
-
|
|
56
|
-
// Build tabs: discovered panels first, then chat
|
|
57
|
-
tabsEl.innerHTML = '';
|
|
58
|
-
|
|
59
|
-
for (const panel of panels) {
|
|
60
|
-
addTab(panel.name, panel.name);
|
|
61
|
-
}
|
|
62
|
-
addTab('chat', 'Chat');
|
|
63
|
-
|
|
64
|
-
// Inject panel HTML
|
|
65
|
-
for (const panel of panels) {
|
|
66
|
-
if (!loadedPanels.has(panel.name)) {
|
|
67
|
-
const container = document.createElement('div');
|
|
68
|
-
container.className = 'panel-view';
|
|
69
|
-
container.id = `view-${panel.name}`;
|
|
70
|
-
container.dataset.api = API;
|
|
71
|
-
panelsEl.appendChild(container);
|
|
72
|
-
injectPanel(container, panel.html);
|
|
73
|
-
loadedPanels.set(panel.name, container);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Remove panels for services that were unloaded
|
|
78
|
-
const activeNames = new Set(panels.map(p => p.name));
|
|
79
|
-
for (const [name, el] of loadedPanels) {
|
|
80
|
-
if (!activeNames.has(name)) {
|
|
81
|
-
el.remove();
|
|
82
|
-
loadedPanels.delete(name);
|
|
83
|
-
// Remove tab
|
|
84
|
-
tabsEl.querySelector(`[data-view="${name}"]`)?.remove();
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Activate first tab if none active
|
|
89
|
-
if (!activeTab || !document.getElementById(`view-${activeTab}`)) {
|
|
90
|
-
const first = panels[0]?.name || 'chat';
|
|
91
|
-
switchTab(first);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
setStatus('ok', `${panels.length} panels`);
|
|
95
|
-
} catch (e) {
|
|
96
|
-
setStatus('err', e.message);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function addTab(name, label) {
|
|
101
|
-
const btn = document.createElement('button');
|
|
102
|
-
btn.className = 'tab' + (activeTab === name ? ' active' : '');
|
|
103
|
-
btn.dataset.view = name;
|
|
104
|
-
btn.textContent = label;
|
|
105
|
-
btn.addEventListener('click', () => switchTab(name));
|
|
106
|
-
tabsEl.appendChild(btn);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function switchTab(name) {
|
|
110
|
-
activeTab = name;
|
|
111
|
-
|
|
112
|
-
// Update tab highlight
|
|
113
|
-
tabsEl.querySelectorAll('.tab').forEach(t => {
|
|
114
|
-
t.classList.toggle('active', t.dataset.view === name);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// Show/hide panels
|
|
118
|
-
document.querySelectorAll('.panel-view').forEach(v => {
|
|
119
|
-
v.classList.toggle('active', v.id === `view-${name}`);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// Lazy-start chat session
|
|
123
|
-
if (name === 'chat') {
|
|
124
|
-
if (!chatSessionId) chatCreateSession();
|
|
125
|
-
document.getElementById('chat-input')?.focus();
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function injectPanel(container, html) {
|
|
130
|
-
// Inject HTML without scripts
|
|
131
|
-
const temp = document.createElement('div');
|
|
132
|
-
temp.innerHTML = html;
|
|
133
|
-
|
|
134
|
-
// Extract scripts
|
|
135
|
-
const scripts = [];
|
|
136
|
-
temp.querySelectorAll('script').forEach(s => {
|
|
137
|
-
scripts.push(s.textContent);
|
|
138
|
-
s.remove();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
// Inject HTML
|
|
142
|
-
container.innerHTML = temp.innerHTML;
|
|
143
|
-
|
|
144
|
-
// Execute scripts in order
|
|
145
|
-
for (const code of scripts) {
|
|
146
|
-
const s = document.createElement('script');
|
|
147
|
-
s.textContent = code;
|
|
148
|
-
container.appendChild(s);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function setStatus(state, text) {
|
|
153
|
-
statusEl.className = 'status ' + state;
|
|
154
|
-
statusEl.querySelector('.label').textContent = text;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// =============================================================================
|
|
158
|
-
// Chat
|
|
159
|
-
// =============================================================================
|
|
160
|
-
|
|
161
|
-
let chatSessionId = null;
|
|
162
|
-
let chatStreaming = false;
|
|
163
|
-
let chatCurrentEl = null;
|
|
164
|
-
let chatCurrentText = '';
|
|
165
|
-
|
|
166
|
-
function chatEl(id) { return document.getElementById(id); }
|
|
167
|
-
|
|
168
|
-
async function chatCreateSession() {
|
|
169
|
-
try {
|
|
170
|
-
const res = await fetch(`${API}/agent/sessions`, { method: 'POST' });
|
|
171
|
-
const data = await res.json();
|
|
172
|
-
if (data.error) throw new Error(data.error);
|
|
173
|
-
chatSessionId = data.id;
|
|
174
|
-
chatConnectSSE();
|
|
175
|
-
const empty = chatEl('chat-messages').querySelector('.chat-empty');
|
|
176
|
-
if (empty) empty.remove();
|
|
177
|
-
} catch (e) {
|
|
178
|
-
chatAddMsg('system', `Failed to start session: ${e.message}`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function chatConnectSSE() {
|
|
183
|
-
if (!chatSessionId) return;
|
|
184
|
-
fetch(`${API}/agent/sessions/${chatSessionId}/events`)
|
|
185
|
-
.then(res => {
|
|
186
|
-
if (!res.ok) throw new Error(`SSE ${res.status}`);
|
|
187
|
-
chatReadSSE(res.body.getReader());
|
|
188
|
-
})
|
|
189
|
-
.catch(e => {
|
|
190
|
-
chatAddMsg('system', `Disconnected: ${e.message}`);
|
|
191
|
-
setTimeout(() => { if (chatSessionId) chatConnectSSE(); }, 3000);
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
async function chatReadSSE(reader) {
|
|
196
|
-
const dec = new TextDecoder();
|
|
197
|
-
let buf = '';
|
|
198
|
-
try {
|
|
199
|
-
while (true) {
|
|
200
|
-
const { done, value } = await reader.read();
|
|
201
|
-
if (done) break;
|
|
202
|
-
buf += dec.decode(value, { stream: true });
|
|
203
|
-
const lines = buf.split('\n');
|
|
204
|
-
buf = lines.pop() || '';
|
|
205
|
-
for (const line of lines) {
|
|
206
|
-
if (line.startsWith('data: ')) {
|
|
207
|
-
try { chatHandleEvent(JSON.parse(line.slice(6))); } catch {}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
} catch {}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function chatHandleEvent(e) {
|
|
215
|
-
switch (e.type) {
|
|
216
|
-
case 'agent_start':
|
|
217
|
-
chatStreaming = true;
|
|
218
|
-
chatEl('chat-send').textContent = 'Stop';
|
|
219
|
-
break;
|
|
220
|
-
case 'agent_end':
|
|
221
|
-
chatStreaming = false;
|
|
222
|
-
chatFinish();
|
|
223
|
-
chatEl('chat-send').textContent = 'Send';
|
|
224
|
-
break;
|
|
225
|
-
case 'message_update': {
|
|
226
|
-
const d = e.assistantMessageEvent;
|
|
227
|
-
if (d?.type === 'text_delta') {
|
|
228
|
-
chatEnsure();
|
|
229
|
-
chatCurrentText += d.delta;
|
|
230
|
-
chatRender();
|
|
231
|
-
}
|
|
232
|
-
break;
|
|
233
|
-
}
|
|
234
|
-
case 'tool_execution_start':
|
|
235
|
-
chatEnsure();
|
|
236
|
-
chatAddTool(e.toolCallId, e.toolName, e.args);
|
|
237
|
-
break;
|
|
238
|
-
case 'tool_execution_update':
|
|
239
|
-
chatUpdateTool(e.toolCallId, e.partialResult);
|
|
240
|
-
break;
|
|
241
|
-
case 'tool_execution_end':
|
|
242
|
-
chatUpdateTool(e.toolCallId, e.result, e.isError);
|
|
243
|
-
break;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function chatEnsure() {
|
|
248
|
-
if (chatCurrentEl) return;
|
|
249
|
-
chatCurrentEl = document.createElement('div');
|
|
250
|
-
chatCurrentEl.className = 'chat-msg';
|
|
251
|
-
chatCurrentEl.innerHTML = '<div class="chat-msg-role assistant">assistant</div><div class="chat-msg-content"></div>';
|
|
252
|
-
chatEl('chat-messages').appendChild(chatCurrentEl);
|
|
253
|
-
chatCurrentText = '';
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function chatRender() {
|
|
257
|
-
if (!chatCurrentEl) return;
|
|
258
|
-
let t = chatCurrentEl.querySelector('.chat-text');
|
|
259
|
-
if (!t) {
|
|
260
|
-
t = document.createElement('span');
|
|
261
|
-
t.className = 'chat-text';
|
|
262
|
-
const c = chatCurrentEl.querySelector('.chat-msg-content');
|
|
263
|
-
c.insertBefore(t, c.firstChild);
|
|
264
|
-
}
|
|
265
|
-
t.innerHTML = chatMd(chatCurrentText) + '<span class="chat-cursor"></span>';
|
|
266
|
-
chatScroll();
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function chatFinish() {
|
|
270
|
-
if (!chatCurrentEl) return;
|
|
271
|
-
const t = chatCurrentEl.querySelector('.chat-text');
|
|
272
|
-
if (t) t.innerHTML = chatMd(chatCurrentText);
|
|
273
|
-
chatCurrentEl.querySelector('.chat-cursor')?.remove();
|
|
274
|
-
chatCurrentEl = null;
|
|
275
|
-
chatCurrentText = '';
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function chatAddTool(id, name, args) {
|
|
279
|
-
const preview = args
|
|
280
|
-
? Object.values(args).map(v => { const s = typeof v === 'string' ? v : JSON.stringify(v); return s.length > 50 ? s.slice(0, 50) + '…' : s; }).join(', ')
|
|
281
|
-
: '';
|
|
282
|
-
const el = document.createElement('div');
|
|
283
|
-
el.className = 'chat-tool';
|
|
284
|
-
el.dataset.toolCallId = id;
|
|
285
|
-
el.innerHTML = `
|
|
286
|
-
<div class="chat-tool-header" onclick="this.querySelector('.chat-tool-arrow').classList.toggle('open');this.nextElementSibling.classList.toggle('open')">
|
|
287
|
-
<span class="chat-tool-arrow">▶</span>
|
|
288
|
-
<span>${esc(name)}(${esc(preview)})</span>
|
|
289
|
-
</div>
|
|
290
|
-
<div class="chat-tool-body"></div>`;
|
|
291
|
-
chatCurrentEl.querySelector('.chat-msg-content').appendChild(el);
|
|
292
|
-
chatScroll();
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function chatUpdateTool(id, result, isError) {
|
|
296
|
-
const el = chatCurrentEl?.querySelector(`[data-tool-call-id="${id}"]`);
|
|
297
|
-
if (!el) return;
|
|
298
|
-
const body = el.querySelector('.chat-tool-body');
|
|
299
|
-
const text = result?.content?.filter(c => c.type === 'text').map(c => c.text).join('') || '';
|
|
300
|
-
body.textContent = text.slice(-2000);
|
|
301
|
-
if (isError) body.classList.add('chat-tool-error');
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function chatAddMsg(role, text) {
|
|
305
|
-
const el = document.createElement('div');
|
|
306
|
-
el.className = 'chat-msg';
|
|
307
|
-
el.innerHTML = `<div class="chat-msg-role ${role}">${role === 'user' ? 'you' : role}</div><div class="chat-msg-content">${esc(text)}</div>`;
|
|
308
|
-
chatEl('chat-messages').appendChild(el);
|
|
309
|
-
chatScroll();
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function chatMd(text) {
|
|
313
|
-
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, l, c) => `<pre><code>${esc(c.trimEnd())}</code></pre>`);
|
|
314
|
-
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
315
|
-
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
316
|
-
return text.split(/(<pre>[\s\S]*?<\/pre>)/g).map(p => p.startsWith('<pre>') ? p : p.replace(/\n/g, '<br>')).join('');
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function chatScroll() {
|
|
320
|
-
requestAnimationFrame(() => {
|
|
321
|
-
const el = chatEl('chat-messages');
|
|
322
|
-
el.scrollTop = el.scrollHeight;
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async function chatSend() {
|
|
327
|
-
if (!chatSessionId) return;
|
|
328
|
-
if (chatStreaming) {
|
|
329
|
-
fetch(`${API}/agent/sessions/${chatSessionId}/abort`, { method: 'POST' }).catch(() => {});
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
const input = chatEl('chat-input');
|
|
333
|
-
const text = input.value.trim();
|
|
334
|
-
if (!text) return;
|
|
335
|
-
input.value = '';
|
|
336
|
-
input.style.height = '36px';
|
|
337
|
-
chatAddMsg('user', text);
|
|
338
|
-
chatFinish();
|
|
339
|
-
try {
|
|
340
|
-
const res = await fetch(`${API}/agent/sessions/${chatSessionId}/message`, {
|
|
341
|
-
method: 'POST',
|
|
342
|
-
headers: { 'Content-Type': 'application/json' },
|
|
343
|
-
body: JSON.stringify({ message: text }),
|
|
344
|
-
});
|
|
345
|
-
const data = await res.json();
|
|
346
|
-
if (data.error) chatAddMsg('system', data.error);
|
|
347
|
-
} catch (e) {
|
|
348
|
-
chatAddMsg('system', e.message);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Chat input handlers
|
|
353
|
-
chatEl('chat-send').addEventListener('click', chatSend);
|
|
354
|
-
chatEl('chat-input').addEventListener('keydown', (e) => {
|
|
355
|
-
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); chatSend(); }
|
|
356
|
-
});
|
|
357
|
-
chatEl('chat-input').addEventListener('input', () => {
|
|
358
|
-
const el = chatEl('chat-input');
|
|
359
|
-
el.style.height = '36px';
|
|
360
|
-
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
// =============================================================================
|
|
364
|
-
// Init
|
|
365
|
-
// =============================================================================
|
|
366
|
-
|
|
367
|
-
discoverPanels();
|
|
368
|
-
// Re-discover periodically (picks up loaded/unloaded services)
|
|
369
|
-
setInterval(discoverPanels, 30000);
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>reef</title>
|
|
7
|
-
<link rel="stylesheet" href="/ui/static/style.css">
|
|
8
|
-
</head>
|
|
9
|
-
<body>
|
|
10
|
-
<div id="app">
|
|
11
|
-
<header>
|
|
12
|
-
<h1>▸ reef</h1>
|
|
13
|
-
<nav class="tabs" id="tabs"></nav>
|
|
14
|
-
<div class="status" id="status">
|
|
15
|
-
<span class="dot"></span>
|
|
16
|
-
<span class="label">loading</span>
|
|
17
|
-
</div>
|
|
18
|
-
</header>
|
|
19
|
-
|
|
20
|
-
<div id="panels"></div>
|
|
21
|
-
|
|
22
|
-
<!-- Chat is always built-in, not a panel -->
|
|
23
|
-
<div class="panel-view" id="view-chat">
|
|
24
|
-
<div class="chat-container">
|
|
25
|
-
<div class="chat-messages" id="chat-messages">
|
|
26
|
-
<div class="chat-empty">
|
|
27
|
-
<div class="chat-empty-title">reef agent</div>
|
|
28
|
-
<div class="chat-empty-sub">Send a message to start a session</div>
|
|
29
|
-
</div>
|
|
30
|
-
</div>
|
|
31
|
-
<div class="chat-input-area">
|
|
32
|
-
<textarea id="chat-input" placeholder="Send a message… (Enter to send, Shift+Enter for newline)" rows="1"></textarea>
|
|
33
|
-
<button id="chat-send">Send</button>
|
|
34
|
-
</div>
|
|
35
|
-
</div>
|
|
36
|
-
</div>
|
|
37
|
-
</div>
|
|
38
|
-
|
|
39
|
-
<script>const PANEL_API = '/ui/api';</script>
|
|
40
|
-
<script src="/ui/static/app.js"></script>
|
|
41
|
-
</body>
|
|
42
|
-
</html>
|