agent-office 0.5.0 → 0.6.1
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 +1 -1
- package/README.md +259 -228
- package/dist/commands/cron-requests.d.ts +7 -0
- package/dist/commands/cron-requests.d.ts.map +1 -0
- package/dist/commands/cron-requests.js +31 -0
- package/dist/commands/cron-requests.js.map +1 -0
- package/dist/commands/crons.d.ts +10 -0
- package/dist/commands/crons.d.ts.map +1 -0
- package/dist/commands/crons.js +45 -0
- package/dist/commands/crons.js.map +1 -0
- package/dist/commands/hello.d.ts +5 -0
- package/dist/commands/hello.d.ts.map +1 -0
- package/dist/commands/hello.js +4 -0
- package/dist/commands/hello.js.map +1 -0
- package/dist/commands/messages.d.ts +5 -0
- package/dist/commands/messages.d.ts.map +1 -0
- package/dist/commands/messages.js +18 -0
- package/dist/commands/messages.js.map +1 -0
- package/dist/commands/sessions.d.ts +13 -0
- package/dist/commands/sessions.d.ts.map +1 -0
- package/dist/commands/sessions.js +58 -0
- package/dist/commands/sessions.js.map +1 -0
- package/dist/commands/task-columns.d.ts +2 -0
- package/dist/commands/task-columns.d.ts.map +1 -0
- package/dist/commands/task-columns.js +13 -0
- package/dist/commands/task-columns.js.map +1 -0
- package/dist/commands/tasks.d.ts +11 -0
- package/dist/commands/tasks.d.ts.map +1 -0
- package/dist/commands/tasks.js +75 -0
- package/dist/commands/tasks.js.map +1 -0
- package/dist/config.test.d.ts +2 -0
- package/dist/config.test.d.ts.map +1 -0
- package/dist/config.test.js +50 -0
- package/dist/config.test.js.map +1 -0
- package/dist/db/index.d.ts +6 -70
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +4 -11
- package/dist/db/index.js.map +1 -0
- package/dist/db/mock-storage.d.ts +79 -0
- package/dist/db/mock-storage.d.ts.map +1 -0
- package/dist/db/mock-storage.js +381 -0
- package/dist/db/mock-storage.js.map +1 -0
- package/dist/db/mock-storage.test.d.ts +2 -0
- package/dist/db/mock-storage.test.d.ts.map +1 -0
- package/dist/db/mock-storage.test.js +234 -0
- package/dist/db/mock-storage.test.js.map +1 -0
- package/dist/db/postgresql-storage.d.ts +10 -8
- package/dist/db/postgresql-storage.d.ts.map +1 -0
- package/dist/db/postgresql-storage.js +76 -42
- package/dist/db/postgresql-storage.js.map +1 -0
- package/dist/db/sqlite-storage.d.ts +9 -8
- package/dist/db/sqlite-storage.d.ts.map +1 -0
- package/dist/db/sqlite-storage.js +75 -41
- package/dist/db/sqlite-storage.js.map +1 -0
- package/dist/db/storage-base.d.ts +7 -8
- package/dist/db/storage-base.d.ts.map +1 -0
- package/dist/db/storage-base.js +3 -2
- package/dist/db/storage-base.js.map +1 -0
- package/dist/db/storage.d.ts +12 -12
- package/dist/db/storage.d.ts.map +1 -0
- package/dist/db/storage.js +1 -0
- package/dist/db/storage.js.map +1 -0
- package/dist/db/types.d.ts +67 -0
- package/dist/db/types.d.ts.map +1 -0
- package/dist/db/types.js +2 -0
- package/dist/db/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +397 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +49 -0
- package/dist/index.test.js.map +1 -0
- package/dist/lib/output.d.ts +2 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +8 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/services/cron-service.constraints.test.d.ts +2 -0
- package/dist/services/cron-service.constraints.test.d.ts.map +1 -0
- package/dist/services/cron-service.constraints.test.js +90 -0
- package/dist/services/cron-service.constraints.test.js.map +1 -0
- package/dist/services/cron-service.d.ts +45 -0
- package/dist/services/cron-service.d.ts.map +1 -0
- package/dist/services/cron-service.js +157 -0
- package/dist/services/cron-service.js.map +1 -0
- package/dist/services/cron-service.test.d.ts +2 -0
- package/dist/services/cron-service.test.d.ts.map +1 -0
- package/dist/services/cron-service.test.js +280 -0
- package/dist/services/cron-service.test.js.map +1 -0
- package/dist/services/index.d.ts +5 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +5 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/message-service.d.ts +16 -0
- package/dist/services/message-service.d.ts.map +1 -0
- package/dist/services/message-service.js +39 -0
- package/dist/services/message-service.js.map +1 -0
- package/dist/services/message-service.test.d.ts +2 -0
- package/dist/services/message-service.test.d.ts.map +1 -0
- package/dist/services/message-service.test.js +145 -0
- package/dist/services/message-service.test.js.map +1 -0
- package/dist/services/session-service.constraints.test.d.ts +2 -0
- package/dist/services/session-service.constraints.test.d.ts.map +1 -0
- package/dist/services/session-service.constraints.test.js +34 -0
- package/dist/services/session-service.constraints.test.js.map +1 -0
- package/dist/services/session-service.d.ts +27 -0
- package/dist/services/session-service.d.ts.map +1 -0
- package/dist/services/session-service.js +55 -0
- package/dist/services/session-service.js.map +1 -0
- package/dist/services/session-service.test.d.ts +2 -0
- package/dist/services/session-service.test.d.ts.map +1 -0
- package/dist/services/session-service.test.js +87 -0
- package/dist/services/session-service.test.js.map +1 -0
- package/dist/services/task-service.d.ts +25 -0
- package/dist/services/task-service.d.ts.map +1 -0
- package/dist/services/task-service.js +87 -0
- package/dist/services/task-service.js.map +1 -0
- package/dist/services/task-service.test.d.ts +2 -0
- package/dist/services/task-service.test.d.ts.map +1 -0
- package/dist/services/task-service.test.js +180 -0
- package/dist/services/task-service.test.js.map +1 -0
- package/package.json +41 -42
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -317
- package/dist/commands/communicator.d.ts +0 -9
- package/dist/commands/communicator.js +0 -2232
- package/dist/commands/manage.d.ts +0 -5
- package/dist/commands/manage.js +0 -20
- package/dist/commands/notifier.d.ts +0 -11
- package/dist/commands/notifier.js +0 -100
- package/dist/commands/screensaver.d.ts +0 -8
- package/dist/commands/screensaver.js +0 -1280
- package/dist/commands/serve.d.ts +0 -13
- package/dist/commands/serve.js +0 -95
- package/dist/commands/task-board.d.ts +0 -29
- package/dist/commands/task-board.js +0 -251
- package/dist/commands/worker.d.ts +0 -16
- package/dist/commands/worker.js +0 -145
- package/dist/db/migrate.d.ts +0 -2
- package/dist/db/migrate.js +0 -3
- package/dist/lib/agentic-coding-server.d.ts +0 -66
- package/dist/lib/agentic-coding-server.js +0 -7
- package/dist/lib/notifier.d.ts +0 -18
- package/dist/lib/notifier.js +0 -15
- package/dist/lib/opencode-coding-server.d.ts +0 -11
- package/dist/lib/opencode-coding-server.js +0 -66
- package/dist/lib/pi-coding-server.d.ts +0 -20
- package/dist/lib/pi-coding-server.js +0 -162
- package/dist/manage/app.d.ts +0 -6
- package/dist/manage/app.js +0 -128
- package/dist/manage/components/AgentCode.d.ts +0 -8
- package/dist/manage/components/AgentCode.js +0 -73
- package/dist/manage/components/CreateSession.d.ts +0 -8
- package/dist/manage/components/CreateSession.js +0 -37
- package/dist/manage/components/CronList.d.ts +0 -9
- package/dist/manage/components/CronList.js +0 -321
- package/dist/manage/components/CronRequests.d.ts +0 -8
- package/dist/manage/components/CronRequests.js +0 -181
- package/dist/manage/components/DeleteSession.d.ts +0 -7
- package/dist/manage/components/DeleteSession.js +0 -55
- package/dist/manage/components/InjectText.d.ts +0 -8
- package/dist/manage/components/InjectText.js +0 -51
- package/dist/manage/components/ItemSelector.d.ts +0 -7
- package/dist/manage/components/ItemSelector.js +0 -20
- package/dist/manage/components/MenuSelect.d.ts +0 -13
- package/dist/manage/components/MenuSelect.js +0 -22
- package/dist/manage/components/MyMail.d.ts +0 -9
- package/dist/manage/components/MyMail.js +0 -143
- package/dist/manage/components/Profile.d.ts +0 -8
- package/dist/manage/components/Profile.js +0 -60
- package/dist/manage/components/ReadMail.d.ts +0 -8
- package/dist/manage/components/ReadMail.js +0 -110
- package/dist/manage/components/SendMessage.d.ts +0 -9
- package/dist/manage/components/SendMessage.js +0 -79
- package/dist/manage/components/SessionList.d.ts +0 -9
- package/dist/manage/components/SessionList.js +0 -608
- package/dist/manage/components/SessionSidebar.d.ts +0 -6
- package/dist/manage/components/SessionSidebar.js +0 -24
- package/dist/manage/components/TailMessages.d.ts +0 -8
- package/dist/manage/components/TailMessages.js +0 -126
- package/dist/manage/hooks/useApi.d.ts +0 -147
- package/dist/manage/hooks/useApi.js +0 -181
- package/dist/server/cron.d.ts +0 -25
- package/dist/server/cron.js +0 -107
- package/dist/server/index.d.ts +0 -4
- package/dist/server/index.js +0 -22
- package/dist/server/routes.d.ts +0 -13
- package/dist/server/routes.js +0 -1396
|
@@ -1,2232 +0,0 @@
|
|
|
1
|
-
import express from "express";
|
|
2
|
-
import { exec } from "child_process";
|
|
3
|
-
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
4
|
-
import { dirname } from "path";
|
|
5
|
-
// ── API helpers ───────────────────────────────────────────────────────────────
|
|
6
|
-
async function apiFetch(agentUrl, password, path, init = {}) {
|
|
7
|
-
const res = await fetch(`${agentUrl}${path}`, {
|
|
8
|
-
...init,
|
|
9
|
-
headers: {
|
|
10
|
-
"Content-Type": "application/json",
|
|
11
|
-
"Authorization": `Bearer ${password}`,
|
|
12
|
-
...(init.headers ?? {}),
|
|
13
|
-
},
|
|
14
|
-
});
|
|
15
|
-
if (!res.ok) {
|
|
16
|
-
const body = await res.json().catch(() => ({}));
|
|
17
|
-
throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
18
|
-
}
|
|
19
|
-
return res.json();
|
|
20
|
-
}
|
|
21
|
-
async function getHumanName(agentUrl, password) {
|
|
22
|
-
const cfg = await apiFetch(agentUrl, password, "/config");
|
|
23
|
-
return cfg.human_name ?? "Human";
|
|
24
|
-
}
|
|
25
|
-
async function fetchSessions(agentUrl, password) {
|
|
26
|
-
return apiFetch(agentUrl, password, "/sessions");
|
|
27
|
-
}
|
|
28
|
-
async function fetchCoworkerStatus(agentUrl, password, coworker) {
|
|
29
|
-
const sessions = await fetchSessions(agentUrl, password);
|
|
30
|
-
const session = sessions.find((s) => s.name === coworker);
|
|
31
|
-
return session?.status ?? null;
|
|
32
|
-
}
|
|
33
|
-
async function fetchMessages(agentUrl, password, humanName, coworker) {
|
|
34
|
-
const [sent, received] = await Promise.all([
|
|
35
|
-
apiFetch(agentUrl, password, `/messages/${encodeURIComponent(humanName)}?sent=true`),
|
|
36
|
-
apiFetch(agentUrl, password, `/messages/${encodeURIComponent(humanName)}`),
|
|
37
|
-
]);
|
|
38
|
-
// sent: from humanName → coworker
|
|
39
|
-
const sentToCoworker = sent.filter((m) => m.to_name === coworker);
|
|
40
|
-
// received: to humanName, from coworker
|
|
41
|
-
const receivedFromCoworker = received.filter((m) => m.from_name === coworker);
|
|
42
|
-
const all = [...sentToCoworker, ...receivedFromCoworker];
|
|
43
|
-
all.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
44
|
-
return all;
|
|
45
|
-
}
|
|
46
|
-
async function markRead(agentUrl, password, id) {
|
|
47
|
-
await apiFetch(agentUrl, password, `/messages/${id}/read`, { method: "POST" });
|
|
48
|
-
}
|
|
49
|
-
async function fetchCronRequests(agentUrl, password, status) {
|
|
50
|
-
const params = status ? `?status=${encodeURIComponent(status)}` : "";
|
|
51
|
-
return apiFetch(agentUrl, password, `/cron-requests${params}`);
|
|
52
|
-
}
|
|
53
|
-
async function approveCronRequest(agentUrl, password, id, notes) {
|
|
54
|
-
await apiFetch(agentUrl, password, `/cron-requests/${id}/approve`, {
|
|
55
|
-
method: "POST",
|
|
56
|
-
body: JSON.stringify({ notes })
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
async function rejectCronRequest(agentUrl, password, id, notes) {
|
|
60
|
-
await apiFetch(agentUrl, password, `/cron-requests/${id}/reject`, {
|
|
61
|
-
method: "POST",
|
|
62
|
-
body: JSON.stringify({ notes })
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
// ── HTML helpers ──────────────────────────────────────────────────────────────
|
|
66
|
-
function escapeHtml(str) {
|
|
67
|
-
return str
|
|
68
|
-
.replace(/&/g, "&")
|
|
69
|
-
.replace(/</g, "<")
|
|
70
|
-
.replace(/>/g, ">")
|
|
71
|
-
.replace(/"/g, """)
|
|
72
|
-
.replace(/'/g, "'");
|
|
73
|
-
}
|
|
74
|
-
function formatTime(iso) {
|
|
75
|
-
return new Date(iso).toLocaleString(undefined, {
|
|
76
|
-
month: "short", day: "numeric",
|
|
77
|
-
hour: "2-digit", minute: "2-digit",
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
function formatFullTime(iso) {
|
|
81
|
-
return new Date(iso).toLocaleString(undefined, {
|
|
82
|
-
year: "numeric",
|
|
83
|
-
month: "short",
|
|
84
|
-
day: "numeric",
|
|
85
|
-
hour: "2-digit",
|
|
86
|
-
minute: "2-digit",
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
function renderMessage(msg, humanName, spacingClass) {
|
|
90
|
-
const isMine = msg.from_name === humanName;
|
|
91
|
-
const bubbleClass = isMine ? "bubble bubble-mine" : "bubble bubble-theirs";
|
|
92
|
-
const wrapClass = `msg-wrap ${isMine ? "msg-wrap-mine" : "msg-wrap-theirs"} ${spacingClass}`;
|
|
93
|
-
// Base64 encode to preserve newlines and special chars reliably
|
|
94
|
-
const bodyEncoded = Buffer.from(msg.body).toString('base64');
|
|
95
|
-
const unreadDot = !isMine && !msg.read ? `<span class="unread-dot"></span>` : "";
|
|
96
|
-
return `<div class="${wrapClass}" data-id="${msg.id}">
|
|
97
|
-
<div class="${bubbleClass}">
|
|
98
|
-
<div class="bubble-body markdown-body" data-markdown-b64="${bodyEncoded}"></div>
|
|
99
|
-
<div class="bubble-time">${unreadDot}${formatTime(msg.created_at)}</div>
|
|
100
|
-
</div>
|
|
101
|
-
</div>`;
|
|
102
|
-
}
|
|
103
|
-
function renderMessages(msgs, humanName) {
|
|
104
|
-
const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : 0;
|
|
105
|
-
const inner = msgs.length === 0
|
|
106
|
-
? `<div class="empty-state">No messages yet. Say hello!</div>`
|
|
107
|
-
: msgs.map((m, i) => {
|
|
108
|
-
const prev = msgs[i - 1];
|
|
109
|
-
// larger gap when the sender changes, tight gap within a run
|
|
110
|
-
const spacingClass = (!prev || prev.from_name !== m.from_name) ? "gap-sender-change" : "gap-same-sender";
|
|
111
|
-
return renderMessage(m, humanName, spacingClass);
|
|
112
|
-
}).join("\n");
|
|
113
|
-
// data-last-id lets the client detect whether new messages actually arrived
|
|
114
|
-
return `<div id="messages-inner" data-last-id="${lastId}">${inner}</div>`;
|
|
115
|
-
}
|
|
116
|
-
// ── Full page ─────────────────────────────────────────────────────────────────
|
|
117
|
-
function renderDropdown(coworker, coworkers) {
|
|
118
|
-
const selected = coworker ?? "";
|
|
119
|
-
const dropdownOptions = coworkers.filter(c => !c.isHuman);
|
|
120
|
-
const totalUnread = dropdownOptions.reduce((sum, c) => sum + (c.unreadMessages ?? 0), 0);
|
|
121
|
-
const unreadDot = totalUnread > 0 ? '<span class="unread-badge"></span>' : '';
|
|
122
|
-
const coworkerOptions = dropdownOptions.map(c => {
|
|
123
|
-
const unreadBadge = c.unreadMessages && c.unreadMessages > 0 ? ` (${c.unreadMessages})` : '';
|
|
124
|
-
return `<option value="${escapeHtml(c.name)}" ${c.name === selected ? 'selected' : ''}>${escapeHtml(c.name)}${unreadBadge}</option>`;
|
|
125
|
-
}).join('');
|
|
126
|
-
return `<select id="coworker-select" class="coworker-select" onchange="switchCoworker(this.value)">
|
|
127
|
-
<option value="">Select coworker…</option>
|
|
128
|
-
${coworkerOptions}
|
|
129
|
-
</select>
|
|
130
|
-
${unreadDot}`;
|
|
131
|
-
}
|
|
132
|
-
function renderPage(coworker, coworkers, msgs, humanName) {
|
|
133
|
-
const msgsHtml = renderMessages(msgs, humanName);
|
|
134
|
-
const selected = coworker ?? "";
|
|
135
|
-
const dropdownHtml = renderDropdown(coworker, coworkers);
|
|
136
|
-
return `<!DOCTYPE html>
|
|
137
|
-
<html lang="en">
|
|
138
|
-
<head>
|
|
139
|
-
<meta charset="UTF-8">
|
|
140
|
-
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
141
|
-
<title>${selected ? escapeHtml(selected) + ' — ' : ''}agent-office</title>
|
|
142
|
-
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
|
143
|
-
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
|
144
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.5.1/github-markdown-light.min.css" media="(prefers-color-scheme: light)">
|
|
145
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.5.1/github-markdown-dark.min.css" media="(prefers-color-scheme: dark)">
|
|
146
|
-
<style>
|
|
147
|
-
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
148
|
-
|
|
149
|
-
:root {
|
|
150
|
-
--bg: #0f1117;
|
|
151
|
-
--surface: #1a1d27;
|
|
152
|
-
--surface2: #22263a;
|
|
153
|
-
--border: #2e3248;
|
|
154
|
-
--accent: #6c8eff;
|
|
155
|
-
--accent-dim: #3d52a0;
|
|
156
|
-
--text: #e2e8f0;
|
|
157
|
-
--text-dim: #8892a4;
|
|
158
|
-
--mine-bg: #2a3a6e;
|
|
159
|
-
--mine-border: #4a6fa5;
|
|
160
|
-
--theirs-bg: #1e2235;
|
|
161
|
-
--theirs-border: #2e3a55;
|
|
162
|
-
--red: #ff6b6b;
|
|
163
|
-
--green: #6bffb8;
|
|
164
|
-
--radius: 18px;
|
|
165
|
-
--radius-sm: 8px;
|
|
166
|
-
--header-h: 56px;
|
|
167
|
-
--input-h: 64px;
|
|
168
|
-
font-size: 16px;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
html, body {
|
|
172
|
-
height: 100%;
|
|
173
|
-
background: var(--bg);
|
|
174
|
-
color: var(--text);
|
|
175
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
176
|
-
overflow: hidden;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/* ── Layout ── */
|
|
180
|
-
.app {
|
|
181
|
-
display: flex;
|
|
182
|
-
flex-direction: column;
|
|
183
|
-
height: 100dvh;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/* ── Header ── */
|
|
187
|
-
.header {
|
|
188
|
-
flex-shrink: 0;
|
|
189
|
-
height: var(--header-h);
|
|
190
|
-
background: var(--surface);
|
|
191
|
-
border-bottom: 1px solid var(--border);
|
|
192
|
-
display: flex;
|
|
193
|
-
align-items: center;
|
|
194
|
-
padding: 0 16px;
|
|
195
|
-
gap: 12px;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
.avatar {
|
|
199
|
-
width: 36px;
|
|
200
|
-
height: 36px;
|
|
201
|
-
border-radius: 50%;
|
|
202
|
-
background: var(--accent-dim);
|
|
203
|
-
color: var(--accent);
|
|
204
|
-
display: flex;
|
|
205
|
-
align-items: center;
|
|
206
|
-
justify-content: center;
|
|
207
|
-
font-weight: 700;
|
|
208
|
-
font-size: 15px;
|
|
209
|
-
flex-shrink: 0;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
.header-info { flex: 1; min-width: 0; }
|
|
213
|
-
.select-wrapper { display: inline-flex; align-items: center; position: relative; }
|
|
214
|
-
.coworker-select {
|
|
215
|
-
font-weight: 600;
|
|
216
|
-
font-size: 15px;
|
|
217
|
-
background: transparent;
|
|
218
|
-
border: none;
|
|
219
|
-
color: var(--text);
|
|
220
|
-
cursor: pointer;
|
|
221
|
-
padding: 0;
|
|
222
|
-
margin: 0;
|
|
223
|
-
outline: none;
|
|
224
|
-
max-width: 180px;
|
|
225
|
-
}
|
|
226
|
-
.coworker-select:focus { color: var(--accent); }
|
|
227
|
-
.coworker-select option { background: var(--surface); color: var(--text); }
|
|
228
|
-
.unread-badge {
|
|
229
|
-
width: 8px;
|
|
230
|
-
height: 8px;
|
|
231
|
-
background: #ff4444;
|
|
232
|
-
border-radius: 50%;
|
|
233
|
-
margin-left: 6px;
|
|
234
|
-
flex-shrink: 0;
|
|
235
|
-
}
|
|
236
|
-
.header-name {
|
|
237
|
-
font-weight: 600;
|
|
238
|
-
font-size: 15px;
|
|
239
|
-
white-space: nowrap;
|
|
240
|
-
overflow: hidden;
|
|
241
|
-
text-overflow: ellipsis;
|
|
242
|
-
}
|
|
243
|
-
.header-sub {
|
|
244
|
-
font-size: 11px;
|
|
245
|
-
color: var(--text-dim);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
.refresh-indicator {
|
|
249
|
-
width: 8px; height: 8px;
|
|
250
|
-
border-radius: 50%;
|
|
251
|
-
background: var(--text-dim);
|
|
252
|
-
flex-shrink: 0;
|
|
253
|
-
transition: background 0.3s;
|
|
254
|
-
}
|
|
255
|
-
.refresh-indicator.active { background: var(--green); }
|
|
256
|
-
|
|
257
|
-
/* ── Reset button ── */
|
|
258
|
-
.header-link {
|
|
259
|
-
color: var(--text-dim);
|
|
260
|
-
text-decoration: none;
|
|
261
|
-
font-size: 18px;
|
|
262
|
-
margin-right: 8px;
|
|
263
|
-
transition: color 0.15s;
|
|
264
|
-
flex-shrink: 0;
|
|
265
|
-
}
|
|
266
|
-
.header-link:hover { color: var(--accent); }
|
|
267
|
-
|
|
268
|
-
.reset-btn {
|
|
269
|
-
background: none;
|
|
270
|
-
border: 1px solid var(--border);
|
|
271
|
-
border-radius: var(--radius-sm);
|
|
272
|
-
color: var(--text-dim);
|
|
273
|
-
cursor: pointer;
|
|
274
|
-
font-size: 12px;
|
|
275
|
-
padding: 5px 10px;
|
|
276
|
-
flex-shrink: 0;
|
|
277
|
-
transition: border-color 0.15s, color 0.15s;
|
|
278
|
-
white-space: nowrap;
|
|
279
|
-
}
|
|
280
|
-
.reset-btn:hover { border-color: var(--red); color: var(--red); }
|
|
281
|
-
.reset-btn:active { opacity: 0.7; }
|
|
282
|
-
.reset-btn.htmx-request { opacity: 0.5; pointer-events: none; }
|
|
283
|
-
|
|
284
|
-
#reset-status {
|
|
285
|
-
position: fixed;
|
|
286
|
-
bottom: 80px;
|
|
287
|
-
left: 50%;
|
|
288
|
-
transform: translateX(-50%);
|
|
289
|
-
background: var(--surface2);
|
|
290
|
-
border: 1px solid var(--border);
|
|
291
|
-
border-radius: var(--radius-sm);
|
|
292
|
-
padding: 8px 16px;
|
|
293
|
-
font-size: 13px;
|
|
294
|
-
pointer-events: none;
|
|
295
|
-
opacity: 0;
|
|
296
|
-
transition: opacity 0.2s;
|
|
297
|
-
white-space: nowrap;
|
|
298
|
-
z-index: 10;
|
|
299
|
-
}
|
|
300
|
-
#reset-status.visible { opacity: 1; }
|
|
301
|
-
|
|
302
|
-
/* ── Message list ── */
|
|
303
|
-
.messages-outer {
|
|
304
|
-
flex: 1;
|
|
305
|
-
overflow-y: auto;
|
|
306
|
-
overscroll-behavior: contain;
|
|
307
|
-
padding: 12px 0 4px;
|
|
308
|
-
scroll-behavior: smooth;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/* Subtle scrollbar */
|
|
312
|
-
.messages-outer::-webkit-scrollbar { width: 4px; }
|
|
313
|
-
.messages-outer::-webkit-scrollbar-track { background: transparent; }
|
|
314
|
-
.messages-outer::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
315
|
-
|
|
316
|
-
#messages { padding: 0 12px; display: flex; flex-direction: column; }
|
|
317
|
-
.gap-same-sender { margin-top: 3px; }
|
|
318
|
-
.gap-sender-change { margin-top: 12px; }
|
|
319
|
-
|
|
320
|
-
.empty-state {
|
|
321
|
-
text-align: center;
|
|
322
|
-
color: var(--text-dim);
|
|
323
|
-
font-size: 14px;
|
|
324
|
-
margin-top: 48px;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/* ── Bubbles ── */
|
|
328
|
-
.msg-wrap { display: flex; }
|
|
329
|
-
.msg-wrap-mine { justify-content: flex-end; }
|
|
330
|
-
.msg-wrap-theirs { justify-content: flex-start; }
|
|
331
|
-
|
|
332
|
-
.bubble {
|
|
333
|
-
max-width: min(72%, 480px);
|
|
334
|
-
padding: 10px 14px 6px;
|
|
335
|
-
border-radius: var(--radius);
|
|
336
|
-
word-break: break-word;
|
|
337
|
-
line-height: 1.45;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
.bubble-mine {
|
|
341
|
-
background: var(--mine-bg);
|
|
342
|
-
border: 1px solid var(--mine-border);
|
|
343
|
-
border-bottom-right-radius: var(--radius-sm);
|
|
344
|
-
}
|
|
345
|
-
.bubble-theirs {
|
|
346
|
-
background: var(--theirs-bg);
|
|
347
|
-
border: 1px solid var(--theirs-border);
|
|
348
|
-
border-bottom-left-radius: var(--radius-sm);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
.bubble-body { font-size: 14.5px; }
|
|
352
|
-
.bubble-body.markdown-body {
|
|
353
|
-
background: transparent;
|
|
354
|
-
color: inherit;
|
|
355
|
-
font-size: 14px;
|
|
356
|
-
line-height: 1.5;
|
|
357
|
-
}
|
|
358
|
-
.bubble-body.markdown-body p { margin: 0 0 8px 0; }
|
|
359
|
-
.bubble-body.markdown-body p:last-child { margin-bottom: 0; }
|
|
360
|
-
.bubble-body.markdown-body pre {
|
|
361
|
-
background: rgba(0,0,0,0.3);
|
|
362
|
-
border-radius: 6px;
|
|
363
|
-
padding: 8px 12px;
|
|
364
|
-
overflow-x: auto;
|
|
365
|
-
margin: 8px 0;
|
|
366
|
-
}
|
|
367
|
-
.bubble-body.markdown-body code {
|
|
368
|
-
background: rgba(0,0,0,0.2);
|
|
369
|
-
padding: 2px 5px;
|
|
370
|
-
border-radius: 3px;
|
|
371
|
-
font-size: 13px;
|
|
372
|
-
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
373
|
-
}
|
|
374
|
-
.bubble-body.markdown-body pre code {
|
|
375
|
-
background: transparent;
|
|
376
|
-
padding: 0;
|
|
377
|
-
}
|
|
378
|
-
.bubble-body.markdown-body ul, .bubble-body.markdown-body ol {
|
|
379
|
-
margin: 8px 0;
|
|
380
|
-
padding-left: 20px;
|
|
381
|
-
}
|
|
382
|
-
.bubble-body.markdown-body li { margin: 4px 0; }
|
|
383
|
-
.bubble-body.markdown-body blockquote {
|
|
384
|
-
border-left: 3px solid var(--accent);
|
|
385
|
-
margin: 8px 0;
|
|
386
|
-
padding-left: 12px;
|
|
387
|
-
color: var(--text-dim);
|
|
388
|
-
}
|
|
389
|
-
.bubble-body.markdown-body h1, .bubble-body.markdown-body h2,
|
|
390
|
-
.bubble-body.markdown-body h3, .bubble-body.markdown-body h4 {
|
|
391
|
-
margin: 12px 0 8px 0;
|
|
392
|
-
font-size: 15px;
|
|
393
|
-
font-weight: 600;
|
|
394
|
-
}
|
|
395
|
-
.bubble-body.markdown-body table {
|
|
396
|
-
border-collapse: collapse;
|
|
397
|
-
margin: 8px 0;
|
|
398
|
-
font-size: 13px;
|
|
399
|
-
}
|
|
400
|
-
.bubble-body.markdown-body th, .bubble-body.markdown-body td {
|
|
401
|
-
border: 1px solid var(--border);
|
|
402
|
-
padding: 6px 10px;
|
|
403
|
-
}
|
|
404
|
-
.bubble-body.markdown-body th {
|
|
405
|
-
background: var(--surface2);
|
|
406
|
-
}
|
|
407
|
-
.bubble-body.markdown-body strong,
|
|
408
|
-
.bubble-body.markdown-body b {
|
|
409
|
-
color: #ffffff;
|
|
410
|
-
font-weight: 600;
|
|
411
|
-
}
|
|
412
|
-
.bubble-time {
|
|
413
|
-
font-size: 10px;
|
|
414
|
-
color: var(--text-dim);
|
|
415
|
-
margin-top: 4px;
|
|
416
|
-
display: flex;
|
|
417
|
-
align-items: center;
|
|
418
|
-
gap: 4px;
|
|
419
|
-
}
|
|
420
|
-
.msg-wrap-mine .bubble-time { justify-content: flex-end; }
|
|
421
|
-
|
|
422
|
-
.unread-dot {
|
|
423
|
-
width: 6px; height: 6px;
|
|
424
|
-
border-radius: 50%;
|
|
425
|
-
background: var(--accent);
|
|
426
|
-
flex-shrink: 0;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/* ── Input bar ── */
|
|
430
|
-
.input-bar {
|
|
431
|
-
flex-shrink: 0;
|
|
432
|
-
background: var(--surface);
|
|
433
|
-
border-top: 1px solid var(--border);
|
|
434
|
-
padding: 10px 12px;
|
|
435
|
-
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
.input-form { display: flex; gap: 8px; align-items: flex-end; }
|
|
439
|
-
|
|
440
|
-
.input-textarea {
|
|
441
|
-
flex: 1;
|
|
442
|
-
background: var(--surface2);
|
|
443
|
-
border: 1px solid var(--border);
|
|
444
|
-
border-radius: 22px;
|
|
445
|
-
color: var(--text);
|
|
446
|
-
font-size: 15px;
|
|
447
|
-
line-height: 1.4;
|
|
448
|
-
padding: 10px 16px;
|
|
449
|
-
resize: none;
|
|
450
|
-
min-height: 44px;
|
|
451
|
-
max-height: 120px;
|
|
452
|
-
overflow-y: auto;
|
|
453
|
-
outline: none;
|
|
454
|
-
font-family: inherit;
|
|
455
|
-
transition: border-color 0.15s;
|
|
456
|
-
}
|
|
457
|
-
.input-textarea:focus { border-color: var(--accent-dim); }
|
|
458
|
-
.input-textarea::placeholder { color: var(--text-dim); }
|
|
459
|
-
|
|
460
|
-
.send-btn {
|
|
461
|
-
width: 44px; height: 44px;
|
|
462
|
-
border-radius: 50%;
|
|
463
|
-
background: var(--accent);
|
|
464
|
-
border: none;
|
|
465
|
-
color: #fff;
|
|
466
|
-
cursor: pointer;
|
|
467
|
-
display: flex;
|
|
468
|
-
align-items: center;
|
|
469
|
-
justify-content: center;
|
|
470
|
-
flex-shrink: 0;
|
|
471
|
-
transition: background 0.15s, transform 0.1s;
|
|
472
|
-
}
|
|
473
|
-
.send-btn:hover { background: #7fa0ff; }
|
|
474
|
-
.send-btn:active { transform: scale(0.93); }
|
|
475
|
-
.send-btn svg { width: 20px; height: 20px; }
|
|
476
|
-
.send-btn.sending { background: var(--accent-dim); opacity: 0.7; cursor: not-allowed; pointer-events: none; }
|
|
477
|
-
.send-btn.sending svg { display: none; }
|
|
478
|
-
.send-btn .spinner {
|
|
479
|
-
display: none;
|
|
480
|
-
width: 18px; height: 18px;
|
|
481
|
-
border: 2px solid rgba(255,255,255,0.3);
|
|
482
|
-
border-top-color: #fff;
|
|
483
|
-
border-radius: 50%;
|
|
484
|
-
animation: spin 0.6s linear infinite;
|
|
485
|
-
}
|
|
486
|
-
.send-btn.sending .spinner { display: block; }
|
|
487
|
-
@keyframes spin { to { transform: rotate(360deg); } }
|
|
488
|
-
|
|
489
|
-
/* ── Send error feedback ── */
|
|
490
|
-
#send-status { min-height: 0; }
|
|
491
|
-
.send-err { color: var(--red); font-size: 12px; }
|
|
492
|
-
|
|
493
|
-
/* ── HTMX request indicator ── */
|
|
494
|
-
.htmx-request .send-btn { background: var(--accent-dim); }
|
|
495
|
-
|
|
496
|
-
/* ── Voice button ── */
|
|
497
|
-
.voice-btn {
|
|
498
|
-
width: 36px; height: 36px;
|
|
499
|
-
border-radius: 50%;
|
|
500
|
-
background: var(--surface2);
|
|
501
|
-
border: 1px solid var(--border);
|
|
502
|
-
color: var(--text-dim);
|
|
503
|
-
cursor: pointer;
|
|
504
|
-
display: flex;
|
|
505
|
-
align-items: center;
|
|
506
|
-
justify-content: center;
|
|
507
|
-
flex-shrink: 0;
|
|
508
|
-
transition: all 0.2s;
|
|
509
|
-
padding: 0;
|
|
510
|
-
}
|
|
511
|
-
.voice-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
512
|
-
.voice-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
513
|
-
.voice-btn svg { width: 18px; height: 18px; }
|
|
514
|
-
.voice-btn.active {
|
|
515
|
-
background: var(--red);
|
|
516
|
-
border-color: var(--red);
|
|
517
|
-
color: #fff;
|
|
518
|
-
animation: voice-pulse 1.5s ease-in-out infinite;
|
|
519
|
-
}
|
|
520
|
-
.voice-btn.connecting {
|
|
521
|
-
background: var(--accent-dim);
|
|
522
|
-
border-color: var(--accent);
|
|
523
|
-
color: var(--accent);
|
|
524
|
-
animation: voice-pulse 0.8s ease-in-out infinite;
|
|
525
|
-
}
|
|
526
|
-
@keyframes voice-pulse {
|
|
527
|
-
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 107, 107, 0.4); }
|
|
528
|
-
50% { box-shadow: 0 0 0 8px rgba(255, 107, 107, 0); }
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/* ── Voice overlay ── */
|
|
532
|
-
.voice-overlay {
|
|
533
|
-
position: absolute;
|
|
534
|
-
top: var(--header-h);
|
|
535
|
-
left: 0; right: 0; bottom: 0;
|
|
536
|
-
background: rgba(15, 17, 23, 0.95);
|
|
537
|
-
z-index: 50;
|
|
538
|
-
display: flex;
|
|
539
|
-
align-items: center;
|
|
540
|
-
justify-content: center;
|
|
541
|
-
backdrop-filter: blur(8px);
|
|
542
|
-
}
|
|
543
|
-
.voice-overlay-content {
|
|
544
|
-
display: flex;
|
|
545
|
-
flex-direction: column;
|
|
546
|
-
align-items: center;
|
|
547
|
-
gap: 24px;
|
|
548
|
-
padding: 32px;
|
|
549
|
-
}
|
|
550
|
-
.voice-visualizer {
|
|
551
|
-
position: relative;
|
|
552
|
-
width: 120px; height: 120px;
|
|
553
|
-
display: flex;
|
|
554
|
-
align-items: center;
|
|
555
|
-
justify-content: center;
|
|
556
|
-
}
|
|
557
|
-
.voice-ring {
|
|
558
|
-
position: absolute;
|
|
559
|
-
width: 100%; height: 100%;
|
|
560
|
-
border-radius: 50%;
|
|
561
|
-
border: 2px solid var(--accent);
|
|
562
|
-
opacity: 0.3;
|
|
563
|
-
animation: voice-ring-pulse 2s ease-in-out infinite;
|
|
564
|
-
}
|
|
565
|
-
.voice-ring-2 { animation-delay: 0.4s; width: 140%; height: 140%; top: -20%; left: -20%; opacity: 0.15; }
|
|
566
|
-
.voice-ring-3 { animation-delay: 0.8s; width: 180%; height: 180%; top: -40%; left: -40%; opacity: 0.08; }
|
|
567
|
-
.voice-overlay.speaking .voice-ring { border-color: var(--green); }
|
|
568
|
-
.voice-overlay.listening .voice-ring { border-color: var(--accent); }
|
|
569
|
-
@keyframes voice-ring-pulse {
|
|
570
|
-
0%, 100% { transform: scale(1); opacity: 0.3; }
|
|
571
|
-
50% { transform: scale(1.1); opacity: 0.1; }
|
|
572
|
-
}
|
|
573
|
-
.voice-avatar {
|
|
574
|
-
width: 64px; height: 64px;
|
|
575
|
-
border-radius: 50%;
|
|
576
|
-
background: var(--accent-dim);
|
|
577
|
-
color: var(--accent);
|
|
578
|
-
display: flex;
|
|
579
|
-
align-items: center;
|
|
580
|
-
justify-content: center;
|
|
581
|
-
font-weight: 700;
|
|
582
|
-
font-size: 24px;
|
|
583
|
-
z-index: 1;
|
|
584
|
-
}
|
|
585
|
-
.voice-status {
|
|
586
|
-
font-size: 16px;
|
|
587
|
-
color: var(--text);
|
|
588
|
-
font-weight: 500;
|
|
589
|
-
}
|
|
590
|
-
.voice-transcript {
|
|
591
|
-
font-size: 14px;
|
|
592
|
-
color: var(--text-dim);
|
|
593
|
-
text-align: center;
|
|
594
|
-
max-width: 400px;
|
|
595
|
-
min-height: 40px;
|
|
596
|
-
line-height: 1.4;
|
|
597
|
-
}
|
|
598
|
-
.voice-end-btn {
|
|
599
|
-
background: var(--red);
|
|
600
|
-
border: none;
|
|
601
|
-
border-radius: 22px;
|
|
602
|
-
color: #fff;
|
|
603
|
-
cursor: pointer;
|
|
604
|
-
font-size: 14px;
|
|
605
|
-
font-weight: 600;
|
|
606
|
-
padding: 10px 24px;
|
|
607
|
-
transition: background 0.15s, transform 0.1s;
|
|
608
|
-
}
|
|
609
|
-
.voice-end-btn:hover { background: #ff8888; }
|
|
610
|
-
.voice-end-btn:active { transform: scale(0.95); }
|
|
611
|
-
</style>
|
|
612
|
-
</head>
|
|
613
|
-
<body>
|
|
614
|
-
<div class="app">
|
|
615
|
-
|
|
616
|
-
<!-- Header -->
|
|
617
|
-
<div class="header">
|
|
618
|
-
<div class="avatar">${selected ? escapeHtml(selected.charAt(0).toUpperCase()) : '?'}</div>
|
|
619
|
-
<div class="header-info">
|
|
620
|
-
<div class="select-wrapper" id="dropdown-wrapper"
|
|
621
|
-
hx-get="/dropdown?coworker=${encodeURIComponent(selected)}"
|
|
622
|
-
hx-trigger="load, every 5s"
|
|
623
|
-
hx-swap="innerHTML">
|
|
624
|
-
${dropdownHtml}
|
|
625
|
-
</div>
|
|
626
|
-
<div class="header-sub"
|
|
627
|
-
id="coworker-status"
|
|
628
|
-
hx-get="/status?coworker=${encodeURIComponent(selected)}"
|
|
629
|
-
hx-trigger="load, every 5s"
|
|
630
|
-
hx-swap="innerHTML"></div>
|
|
631
|
-
</div>
|
|
632
|
-
<a href="/cron-requests" class="header-link" title="Manage cron job requests">⚙️</a>
|
|
633
|
-
<button class="voice-btn" id="voice-btn"
|
|
634
|
-
onclick="toggleVoice()"
|
|
635
|
-
title="Voice chat"
|
|
636
|
-
style="display:none"
|
|
637
|
-
${!selected ? 'disabled' : ''}>
|
|
638
|
-
<svg class="voice-icon-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
639
|
-
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
|
640
|
-
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
|
641
|
-
<line x1="12" y1="19" x2="12" y2="23"></line>
|
|
642
|
-
<line x1="8" y1="23" x2="16" y2="23"></line>
|
|
643
|
-
</svg>
|
|
644
|
-
<div class="voice-icon-stop" style="display:none">
|
|
645
|
-
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
646
|
-
<rect x="6" y="6" width="12" height="12" rx="2"></rect>
|
|
647
|
-
</svg>
|
|
648
|
-
</div>
|
|
649
|
-
</button>
|
|
650
|
-
<button class="reset-btn"
|
|
651
|
-
hx-post="/reset?coworker=${encodeURIComponent(selected)}"
|
|
652
|
-
hx-target="#reset-status"
|
|
653
|
-
hx-swap="innerHTML"
|
|
654
|
-
hx-confirm="Reset session? This will revert them to their first message and re-inject the enrollment prompt."
|
|
655
|
-
hx-on::after-request="showResetStatus()"
|
|
656
|
-
title="Reset session"
|
|
657
|
-
${!selected ? 'disabled' : ''}>
|
|
658
|
-
↺ Reset
|
|
659
|
-
</button>
|
|
660
|
-
<div class="refresh-indicator" id="refresh-dot"
|
|
661
|
-
hx-get="/ping"
|
|
662
|
-
hx-trigger="every 5s"
|
|
663
|
-
hx-swap="none"
|
|
664
|
-
hx-on::before-request="this.classList.add('active')"
|
|
665
|
-
hx-on::after-request="this.classList.remove('active')"></div>
|
|
666
|
-
</div>
|
|
667
|
-
|
|
668
|
-
<div id="reset-status"></div>
|
|
669
|
-
|
|
670
|
-
<!-- Voice overlay -->
|
|
671
|
-
<div id="voice-overlay" class="voice-overlay" style="display:none">
|
|
672
|
-
<div class="voice-overlay-content">
|
|
673
|
-
<div class="voice-visualizer" id="voice-visualizer">
|
|
674
|
-
<div class="voice-ring"></div>
|
|
675
|
-
<div class="voice-ring voice-ring-2"></div>
|
|
676
|
-
<div class="voice-ring voice-ring-3"></div>
|
|
677
|
-
<div class="voice-avatar" id="voice-avatar">?</div>
|
|
678
|
-
</div>
|
|
679
|
-
<div class="voice-status" id="voice-status">Connecting...</div>
|
|
680
|
-
<div class="voice-transcript" id="voice-transcript"></div>
|
|
681
|
-
<button class="voice-end-btn" onclick="toggleVoice()">End Voice Chat</button>
|
|
682
|
-
</div>
|
|
683
|
-
</div>
|
|
684
|
-
|
|
685
|
-
<!-- Messages -->
|
|
686
|
-
<div class="messages-outer" id="messages-outer">
|
|
687
|
-
<div id="messages"
|
|
688
|
-
hx-get="/messages?coworker=${encodeURIComponent(selected)}"
|
|
689
|
-
hx-trigger="load, every 5s"
|
|
690
|
-
hx-swap="innerHTML">
|
|
691
|
-
${msgsHtml}
|
|
692
|
-
</div>
|
|
693
|
-
</div>
|
|
694
|
-
|
|
695
|
-
<!-- Input -->
|
|
696
|
-
<div class="input-bar">
|
|
697
|
-
<div id="send-status"></div>
|
|
698
|
-
<form class="input-form"
|
|
699
|
-
hx-post="/send"
|
|
700
|
-
hx-target="#send-status"
|
|
701
|
-
hx-swap="innerHTML show:no-scroll"
|
|
702
|
-
hx-on::after-request="handleSent(event)"
|
|
703
|
-
hx-on::before-request="this.querySelector('.send-btn').classList.add('sending')"
|
|
704
|
-
hx-encoding="application/x-www-form-urlencoded">
|
|
705
|
-
<input type="hidden" name="coworker" value="${escapeHtml(selected)}">
|
|
706
|
-
<textarea
|
|
707
|
-
class="input-textarea"
|
|
708
|
-
name="body"
|
|
709
|
-
id="msg-input"
|
|
710
|
-
placeholder="${selected ? 'Message ' + escapeHtml(selected) + '…' : 'Select a coworker to message…'}"
|
|
711
|
-
rows="1"
|
|
712
|
-
autocomplete="off"
|
|
713
|
-
autocorrect="on"
|
|
714
|
-
spellcheck="true"
|
|
715
|
-
onkeydown="handleKey(event)"></textarea>
|
|
716
|
-
<button type="submit" class="send-btn" title="Send">
|
|
717
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
718
|
-
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
719
|
-
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
720
|
-
</svg>
|
|
721
|
-
<div class="spinner"></div>
|
|
722
|
-
</button>
|
|
723
|
-
</form>
|
|
724
|
-
</div>
|
|
725
|
-
|
|
726
|
-
</div>
|
|
727
|
-
|
|
728
|
-
<script>
|
|
729
|
-
const outer = document.getElementById('messages-outer')
|
|
730
|
-
const input = document.getElementById('msg-input')
|
|
731
|
-
|
|
732
|
-
let lastSeenId = parseInt(document.querySelector('#messages-inner')?.dataset?.lastId ?? '0', 10)
|
|
733
|
-
let userScrolledUp = false
|
|
734
|
-
|
|
735
|
-
function scrollToBottom() {
|
|
736
|
-
if (!outer) return
|
|
737
|
-
requestAnimationFrame(() => {
|
|
738
|
-
outer.scrollTop = outer.scrollHeight
|
|
739
|
-
// After snapping, clear the flag so future messages auto-scroll again
|
|
740
|
-
userScrolledUp = false
|
|
741
|
-
})
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// Detect when the user manually scrolls up
|
|
745
|
-
outer.addEventListener('scroll', () => {
|
|
746
|
-
const distFromBottom = outer.scrollHeight - outer.scrollTop - outer.clientHeight
|
|
747
|
-
userScrolledUp = distFromBottom > 80
|
|
748
|
-
})
|
|
749
|
-
|
|
750
|
-
// Auto-grow textarea
|
|
751
|
-
input.addEventListener('input', function() {
|
|
752
|
-
this.style.height = 'auto'
|
|
753
|
-
this.style.height = Math.min(this.scrollHeight, 120) + 'px'
|
|
754
|
-
})
|
|
755
|
-
|
|
756
|
-
// Send on Enter (Shift+Enter = newline)
|
|
757
|
-
function handleKey(e) {
|
|
758
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
759
|
-
e.preventDefault()
|
|
760
|
-
const form = e.target.closest('form')
|
|
761
|
-
if (form && input.value.trim()) htmx.trigger(form, 'submit')
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// After send: clear input, re-enable button, force scroll and refresh
|
|
766
|
-
function handleSent(event) {
|
|
767
|
-
const form = event.target
|
|
768
|
-
const btn = form.querySelector('.send-btn')
|
|
769
|
-
btn.classList.remove('sending')
|
|
770
|
-
if (event.detail.successful) {
|
|
771
|
-
input.value = ''
|
|
772
|
-
input.style.height = 'auto'
|
|
773
|
-
input.focus()
|
|
774
|
-
userScrolledUp = false
|
|
775
|
-
lastSeenId = -1
|
|
776
|
-
htmx.trigger(document.getElementById('messages'), 'load')
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// Scroll to bottom whenever new messages arrive, unless user has scrolled up
|
|
781
|
-
document.addEventListener('htmx:afterSwap', (e) => {
|
|
782
|
-
if (e.detail.target.id !== 'messages') return
|
|
783
|
-
const inner = document.getElementById('messages-inner')
|
|
784
|
-
const newLastId = parseInt(inner?.dataset?.lastId ?? '0', 10)
|
|
785
|
-
if (newLastId > lastSeenId) {
|
|
786
|
-
lastSeenId = newLastId
|
|
787
|
-
if (!userScrolledUp) scrollToBottom()
|
|
788
|
-
}
|
|
789
|
-
})
|
|
790
|
-
|
|
791
|
-
// Initial scroll
|
|
792
|
-
scrollToBottom()
|
|
793
|
-
|
|
794
|
-
// Switch to a different coworker — stop any active voice session first
|
|
795
|
-
function switchCoworker(name) {
|
|
796
|
-
if (!name) return
|
|
797
|
-
if (voiceState.active) stopVoice()
|
|
798
|
-
const url = new URL(window.location.href)
|
|
799
|
-
url.searchParams.set('coworker', name)
|
|
800
|
-
window.location.href = url.toString()
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
// Flash the reset status toast then fade it out
|
|
804
|
-
function showResetStatus() {
|
|
805
|
-
const el = document.getElementById('reset-status')
|
|
806
|
-
if (!el) return
|
|
807
|
-
el.classList.add('visible')
|
|
808
|
-
clearTimeout(el._hideTimer)
|
|
809
|
-
el._hideTimer = setTimeout(() => el.classList.remove('visible'), 3000)
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
// Render markdown in chat bubbles
|
|
813
|
-
function renderMarkdown() {
|
|
814
|
-
if (typeof marked === 'undefined') return
|
|
815
|
-
document.querySelectorAll('.markdown-body[data-markdown-b64]').forEach(el => {
|
|
816
|
-
const b64 = el.getAttribute('data-markdown-b64')
|
|
817
|
-
if (b64 && !el.hasAttribute('data-rendered')) {
|
|
818
|
-
// Decode base64 to get original text with preserved newlines and UTF-8 chars
|
|
819
|
-
const binary = atob(b64)
|
|
820
|
-
const text = new TextDecoder().decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
|
|
821
|
-
el.innerHTML = marked.parse(text)
|
|
822
|
-
el.setAttribute('data-rendered', 'true')
|
|
823
|
-
}
|
|
824
|
-
})
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// Initial render
|
|
828
|
-
renderMarkdown()
|
|
829
|
-
|
|
830
|
-
// Re-render after HTMX swaps new content
|
|
831
|
-
document.addEventListener('htmx:afterSwap', () => {
|
|
832
|
-
renderMarkdown()
|
|
833
|
-
})
|
|
834
|
-
|
|
835
|
-
// ── Voice Chat ─────────────────────────────────────────────────────────────
|
|
836
|
-
const SAMPLE_RATE = 24000
|
|
837
|
-
|
|
838
|
-
const voiceState = {
|
|
839
|
-
active: false,
|
|
840
|
-
ws: null,
|
|
841
|
-
audioCtx: null,
|
|
842
|
-
micStream: null,
|
|
843
|
-
scriptProcessor: null,
|
|
844
|
-
playbackQueue: [],
|
|
845
|
-
isPlaying: false,
|
|
846
|
-
nextPlayTime: 0,
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// Check if voice is enabled and show button
|
|
850
|
-
fetch('/voice/config').then(r => r.json()).then(cfg => {
|
|
851
|
-
if (cfg.enabled) {
|
|
852
|
-
const btn = document.getElementById('voice-btn')
|
|
853
|
-
if (btn) btn.style.display = 'flex'
|
|
854
|
-
}
|
|
855
|
-
}).catch(() => {})
|
|
856
|
-
|
|
857
|
-
function toggleVoice() {
|
|
858
|
-
if (voiceState.active) {
|
|
859
|
-
stopVoice()
|
|
860
|
-
} else {
|
|
861
|
-
startVoice()
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
async function startVoice() {
|
|
866
|
-
const select = document.getElementById('coworker-select')
|
|
867
|
-
const coworker = select ? select.value : ''
|
|
868
|
-
if (!coworker) return
|
|
869
|
-
|
|
870
|
-
const btn = document.getElementById('voice-btn')
|
|
871
|
-
const overlay = document.getElementById('voice-overlay')
|
|
872
|
-
const statusEl = document.getElementById('voice-status')
|
|
873
|
-
const transcriptEl = document.getElementById('voice-transcript')
|
|
874
|
-
const avatarEl = document.getElementById('voice-avatar')
|
|
875
|
-
|
|
876
|
-
// Update UI to connecting state
|
|
877
|
-
btn.classList.add('connecting')
|
|
878
|
-
btn.querySelector('.voice-icon-mic').style.display = 'none'
|
|
879
|
-
btn.querySelector('.voice-icon-stop').style.display = 'flex'
|
|
880
|
-
overlay.style.display = 'flex'
|
|
881
|
-
statusEl.textContent = 'Connecting...'
|
|
882
|
-
transcriptEl.textContent = ''
|
|
883
|
-
avatarEl.textContent = coworker.charAt(0).toUpperCase()
|
|
884
|
-
|
|
885
|
-
try {
|
|
886
|
-
// Request ephemeral token from our backend
|
|
887
|
-
const sessRes = await fetch('/voice/session', {
|
|
888
|
-
method: 'POST',
|
|
889
|
-
headers: { 'Content-Type': 'application/json' },
|
|
890
|
-
body: JSON.stringify({ coworker }),
|
|
891
|
-
})
|
|
892
|
-
if (!sessRes.ok) {
|
|
893
|
-
const err = await sessRes.json().catch(() => ({}))
|
|
894
|
-
throw new Error(err.error || 'Failed to create voice session')
|
|
895
|
-
}
|
|
896
|
-
const sessData = await sessRes.json()
|
|
897
|
-
const token = sessData.token
|
|
898
|
-
const instructions = sessData.instructions
|
|
899
|
-
|
|
900
|
-
if (!token) throw new Error('No ephemeral token received')
|
|
901
|
-
|
|
902
|
-
// Request microphone access
|
|
903
|
-
const micStream = await navigator.mediaDevices.getUserMedia({ audio: {
|
|
904
|
-
sampleRate: SAMPLE_RATE,
|
|
905
|
-
channelCount: 1,
|
|
906
|
-
echoCancellation: true,
|
|
907
|
-
noiseSuppression: true,
|
|
908
|
-
autoGainControl: true,
|
|
909
|
-
}})
|
|
910
|
-
voiceState.micStream = micStream
|
|
911
|
-
|
|
912
|
-
// Create audio context for playback
|
|
913
|
-
const audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE })
|
|
914
|
-
voiceState.audioCtx = audioCtx
|
|
915
|
-
voiceState.nextPlayTime = 0
|
|
916
|
-
|
|
917
|
-
// Connect WebSocket to xAI realtime API using subprotocol for auth
|
|
918
|
-
const ws = new WebSocket('wss://api.x.ai/v1/realtime', [
|
|
919
|
-
'xai-client-secret.' + token
|
|
920
|
-
])
|
|
921
|
-
voiceState.ws = ws
|
|
922
|
-
|
|
923
|
-
ws.onopen = () => {
|
|
924
|
-
voiceState.active = true
|
|
925
|
-
btn.classList.remove('connecting')
|
|
926
|
-
btn.classList.add('active')
|
|
927
|
-
statusEl.textContent = 'Listening...'
|
|
928
|
-
overlay.classList.add('listening')
|
|
929
|
-
overlay.classList.remove('speaking')
|
|
930
|
-
|
|
931
|
-
// Configure session with tools
|
|
932
|
-
ws.send(JSON.stringify({
|
|
933
|
-
type: 'session.update',
|
|
934
|
-
session: {
|
|
935
|
-
voice: 'Ara',
|
|
936
|
-
instructions: instructions,
|
|
937
|
-
turn_detection: { type: 'server_vad' },
|
|
938
|
-
audio: {
|
|
939
|
-
input: { format: { type: 'audio/pcm', rate: SAMPLE_RATE } },
|
|
940
|
-
output: { format: { type: 'audio/pcm', rate: SAMPLE_RATE } },
|
|
941
|
-
},
|
|
942
|
-
tools: [
|
|
943
|
-
{
|
|
944
|
-
type: 'function',
|
|
945
|
-
name: 'read',
|
|
946
|
-
description: 'Read a file from the filesystem. Returns the file contents. Use this to examine source code, config files, or any text file.',
|
|
947
|
-
parameters: {
|
|
948
|
-
type: 'object',
|
|
949
|
-
properties: {
|
|
950
|
-
path: { type: 'string', description: 'Absolute or relative file path to read' },
|
|
951
|
-
offset: { type: 'number', description: 'Line number to start reading from (1-indexed). Optional.' },
|
|
952
|
-
limit: { type: 'number', description: 'Maximum number of lines to read. Optional, defaults to 200.' },
|
|
953
|
-
},
|
|
954
|
-
required: ['path'],
|
|
955
|
-
},
|
|
956
|
-
},
|
|
957
|
-
{
|
|
958
|
-
type: 'function',
|
|
959
|
-
name: 'write',
|
|
960
|
-
description: 'Write content to a file, creating it if it does not exist or overwriting if it does. Use this to create new files.',
|
|
961
|
-
parameters: {
|
|
962
|
-
type: 'object',
|
|
963
|
-
properties: {
|
|
964
|
-
path: { type: 'string', description: 'Absolute or relative file path to write' },
|
|
965
|
-
content: { type: 'string', description: 'The full content to write to the file' },
|
|
966
|
-
},
|
|
967
|
-
required: ['path', 'content'],
|
|
968
|
-
},
|
|
969
|
-
},
|
|
970
|
-
{
|
|
971
|
-
type: 'function',
|
|
972
|
-
name: 'edit',
|
|
973
|
-
description: 'Edit a file by replacing an exact string match with new content. The oldString must match exactly (including whitespace and indentation).',
|
|
974
|
-
parameters: {
|
|
975
|
-
type: 'object',
|
|
976
|
-
properties: {
|
|
977
|
-
path: { type: 'string', description: 'Absolute or relative file path to edit' },
|
|
978
|
-
oldText: { type: 'string', description: 'The exact text to find and replace' },
|
|
979
|
-
newText: { type: 'string', description: 'The replacement text' },
|
|
980
|
-
},
|
|
981
|
-
required: ['path', 'oldText', 'newText'],
|
|
982
|
-
},
|
|
983
|
-
},
|
|
984
|
-
{
|
|
985
|
-
type: 'function',
|
|
986
|
-
name: 'bash',
|
|
987
|
-
description: 'Execute a bash command and return its output. Use for running scripts, git commands, build tools, listing files, searching, etc.',
|
|
988
|
-
parameters: {
|
|
989
|
-
type: 'object',
|
|
990
|
-
properties: {
|
|
991
|
-
command: { type: 'string', description: 'The bash command to execute' },
|
|
992
|
-
timeout: { type: 'number', description: 'Timeout in seconds. Optional, defaults to 30.' },
|
|
993
|
-
},
|
|
994
|
-
required: ['command'],
|
|
995
|
-
},
|
|
996
|
-
},
|
|
997
|
-
],
|
|
998
|
-
},
|
|
999
|
-
}))
|
|
1000
|
-
|
|
1001
|
-
// Start streaming microphone audio
|
|
1002
|
-
startMicStreaming(ws, micStream, audioCtx)
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
ws.onmessage = (event) => {
|
|
1006
|
-
const data = JSON.parse(event.data)
|
|
1007
|
-
handleVoiceEvent(data)
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
ws.onerror = () => {
|
|
1011
|
-
statusEl.textContent = 'Connection error'
|
|
1012
|
-
setTimeout(() => stopVoice(), 2000)
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
ws.onclose = () => {
|
|
1016
|
-
if (voiceState.active) {
|
|
1017
|
-
stopVoice()
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
} catch (err) {
|
|
1022
|
-
console.error('Voice start error:', err)
|
|
1023
|
-
const statusEl = document.getElementById('voice-status')
|
|
1024
|
-
if (statusEl) statusEl.textContent = 'Error: ' + (err.message || 'Unknown error')
|
|
1025
|
-
setTimeout(() => stopVoice(), 2500)
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
function startMicStreaming(ws, micStream, audioCtx) {
|
|
1030
|
-
const source = audioCtx.createMediaStreamSource(micStream)
|
|
1031
|
-
// Use ScriptProcessorNode for broad compatibility (including mobile)
|
|
1032
|
-
const bufSize = 4096
|
|
1033
|
-
const processor = audioCtx.createScriptProcessor(bufSize, 1, 1)
|
|
1034
|
-
voiceState.scriptProcessor = processor
|
|
1035
|
-
|
|
1036
|
-
processor.onaudioprocess = (e) => {
|
|
1037
|
-
if (!voiceState.active || ws.readyState !== WebSocket.OPEN) return
|
|
1038
|
-
const inputData = e.inputBuffer.getChannelData(0)
|
|
1039
|
-
|
|
1040
|
-
// Resample if audioCtx sample rate differs from target
|
|
1041
|
-
let pcmFloat
|
|
1042
|
-
if (audioCtx.sampleRate !== SAMPLE_RATE) {
|
|
1043
|
-
const ratio = SAMPLE_RATE / audioCtx.sampleRate
|
|
1044
|
-
const newLen = Math.round(inputData.length * ratio)
|
|
1045
|
-
pcmFloat = new Float32Array(newLen)
|
|
1046
|
-
for (let i = 0; i < newLen; i++) {
|
|
1047
|
-
const srcIdx = i / ratio
|
|
1048
|
-
const lo = Math.floor(srcIdx)
|
|
1049
|
-
const hi = Math.min(lo + 1, inputData.length - 1)
|
|
1050
|
-
const frac = srcIdx - lo
|
|
1051
|
-
pcmFloat[i] = inputData[lo] * (1 - frac) + inputData[hi] * frac
|
|
1052
|
-
}
|
|
1053
|
-
} else {
|
|
1054
|
-
pcmFloat = inputData
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
// Convert Float32 to Int16 PCM
|
|
1058
|
-
const pcm16 = new Int16Array(pcmFloat.length)
|
|
1059
|
-
for (let i = 0; i < pcmFloat.length; i++) {
|
|
1060
|
-
const s = Math.max(-1, Math.min(1, pcmFloat[i]))
|
|
1061
|
-
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
// Base64 encode
|
|
1065
|
-
const bytes = new Uint8Array(pcm16.buffer)
|
|
1066
|
-
let binary = ''
|
|
1067
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
1068
|
-
binary += String.fromCharCode(bytes[i])
|
|
1069
|
-
}
|
|
1070
|
-
const b64 = btoa(binary)
|
|
1071
|
-
|
|
1072
|
-
ws.send(JSON.stringify({
|
|
1073
|
-
type: 'input_audio_buffer.append',
|
|
1074
|
-
audio: b64,
|
|
1075
|
-
}))
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
source.connect(processor)
|
|
1079
|
-
processor.connect(audioCtx.destination)
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
function handleVoiceEvent(data) {
|
|
1083
|
-
const overlay = document.getElementById('voice-overlay')
|
|
1084
|
-
const statusEl = document.getElementById('voice-status')
|
|
1085
|
-
const transcriptEl = document.getElementById('voice-transcript')
|
|
1086
|
-
|
|
1087
|
-
switch (data.type) {
|
|
1088
|
-
case 'input_audio_buffer.speech_started':
|
|
1089
|
-
if (overlay) { overlay.classList.add('listening'); overlay.classList.remove('speaking') }
|
|
1090
|
-
if (statusEl) statusEl.textContent = 'Listening...'
|
|
1091
|
-
break
|
|
1092
|
-
|
|
1093
|
-
case 'input_audio_buffer.speech_stopped':
|
|
1094
|
-
if (statusEl) statusEl.textContent = 'Processing...'
|
|
1095
|
-
break
|
|
1096
|
-
|
|
1097
|
-
case 'conversation.item.input_audio_transcription.completed':
|
|
1098
|
-
if (transcriptEl && data.transcript) {
|
|
1099
|
-
transcriptEl.textContent = 'You: ' + data.transcript
|
|
1100
|
-
}
|
|
1101
|
-
break
|
|
1102
|
-
|
|
1103
|
-
case 'response.function_call_arguments.done':
|
|
1104
|
-
handleToolCall(data)
|
|
1105
|
-
break
|
|
1106
|
-
|
|
1107
|
-
case 'response.output_audio_transcript.delta':
|
|
1108
|
-
if (overlay) { overlay.classList.remove('listening'); overlay.classList.add('speaking') }
|
|
1109
|
-
if (statusEl) statusEl.textContent = 'Speaking...'
|
|
1110
|
-
if (transcriptEl) {
|
|
1111
|
-
const current = transcriptEl.textContent
|
|
1112
|
-
if (current.startsWith('You:') || current.startsWith('[Tool')) {
|
|
1113
|
-
transcriptEl.textContent = data.delta
|
|
1114
|
-
} else {
|
|
1115
|
-
transcriptEl.textContent += data.delta
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
break
|
|
1119
|
-
|
|
1120
|
-
case 'response.output_audio.delta':
|
|
1121
|
-
if (data.delta) {
|
|
1122
|
-
playAudioChunk(data.delta)
|
|
1123
|
-
}
|
|
1124
|
-
break
|
|
1125
|
-
|
|
1126
|
-
case 'response.done':
|
|
1127
|
-
if (overlay) { overlay.classList.add('listening'); overlay.classList.remove('speaking') }
|
|
1128
|
-
if (statusEl) statusEl.textContent = 'Listening...'
|
|
1129
|
-
break
|
|
1130
|
-
|
|
1131
|
-
case 'error':
|
|
1132
|
-
console.error('Voice API error:', data)
|
|
1133
|
-
if (statusEl) statusEl.textContent = 'Error: ' + (data.error?.message || 'Unknown')
|
|
1134
|
-
break
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
async function handleToolCall(event) {
|
|
1139
|
-
const ws = voiceState.ws
|
|
1140
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
1141
|
-
|
|
1142
|
-
const toolName = event.name
|
|
1143
|
-
const callId = event.call_id
|
|
1144
|
-
const args = event.arguments
|
|
1145
|
-
|
|
1146
|
-
const statusEl = document.getElementById('voice-status')
|
|
1147
|
-
const transcriptEl = document.getElementById('voice-transcript')
|
|
1148
|
-
const overlay = document.getElementById('voice-overlay')
|
|
1149
|
-
|
|
1150
|
-
// Show tool execution in UI
|
|
1151
|
-
if (overlay) { overlay.classList.remove('listening', 'speaking') }
|
|
1152
|
-
if (statusEl) statusEl.textContent = 'Running tool: ' + toolName + '...'
|
|
1153
|
-
|
|
1154
|
-
// Parse args for display
|
|
1155
|
-
let argsObj = {}
|
|
1156
|
-
try { argsObj = JSON.parse(args) } catch {}
|
|
1157
|
-
const brief = toolName === 'bash' ? (argsObj.command || '').slice(0, 80) :
|
|
1158
|
-
toolName === 'read' ? argsObj.path || '' :
|
|
1159
|
-
toolName === 'write' ? argsObj.path || '' :
|
|
1160
|
-
toolName === 'edit' ? argsObj.path || '' : ''
|
|
1161
|
-
if (transcriptEl) transcriptEl.textContent = '[Tool: ' + toolName + '] ' + brief
|
|
1162
|
-
|
|
1163
|
-
try {
|
|
1164
|
-
// Execute tool via our backend
|
|
1165
|
-
const toolRes = await fetch('/voice/tool', {
|
|
1166
|
-
method: 'POST',
|
|
1167
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1168
|
-
body: JSON.stringify({ name: toolName, arguments: args }),
|
|
1169
|
-
})
|
|
1170
|
-
const toolData = await toolRes.json()
|
|
1171
|
-
const output = toolData.output || '(no output)'
|
|
1172
|
-
|
|
1173
|
-
// Show brief result
|
|
1174
|
-
const shortOutput = output.length > 120 ? output.slice(0, 120) + '...' : output
|
|
1175
|
-
if (transcriptEl) transcriptEl.textContent = '[Tool: ' + toolName + '] ' + shortOutput
|
|
1176
|
-
|
|
1177
|
-
// Send result back to voice agent
|
|
1178
|
-
ws.send(JSON.stringify({
|
|
1179
|
-
type: 'conversation.item.create',
|
|
1180
|
-
item: {
|
|
1181
|
-
type: 'function_call_output',
|
|
1182
|
-
call_id: callId,
|
|
1183
|
-
output: output,
|
|
1184
|
-
},
|
|
1185
|
-
}))
|
|
1186
|
-
|
|
1187
|
-
// Request the agent to continue
|
|
1188
|
-
ws.send(JSON.stringify({ type: 'response.create' }))
|
|
1189
|
-
|
|
1190
|
-
if (statusEl) statusEl.textContent = 'Processing...'
|
|
1191
|
-
} catch (err) {
|
|
1192
|
-
console.error('Tool execution error:', err)
|
|
1193
|
-
const errMsg = err.message || 'Tool execution failed'
|
|
1194
|
-
|
|
1195
|
-
// Send error back as tool output so the agent can handle it
|
|
1196
|
-
ws.send(JSON.stringify({
|
|
1197
|
-
type: 'conversation.item.create',
|
|
1198
|
-
item: {
|
|
1199
|
-
type: 'function_call_output',
|
|
1200
|
-
call_id: callId,
|
|
1201
|
-
output: 'Error: ' + errMsg,
|
|
1202
|
-
},
|
|
1203
|
-
}))
|
|
1204
|
-
ws.send(JSON.stringify({ type: 'response.create' }))
|
|
1205
|
-
|
|
1206
|
-
if (statusEl) statusEl.textContent = 'Listening...'
|
|
1207
|
-
if (overlay) overlay.classList.add('listening')
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
function playAudioChunk(base64Audio) {
|
|
1212
|
-
if (!voiceState.audioCtx) return
|
|
1213
|
-
const ctx = voiceState.audioCtx
|
|
1214
|
-
|
|
1215
|
-
// Decode base64 to Int16 PCM
|
|
1216
|
-
const binaryStr = atob(base64Audio)
|
|
1217
|
-
const bytes = new Uint8Array(binaryStr.length)
|
|
1218
|
-
for (let i = 0; i < binaryStr.length; i++) {
|
|
1219
|
-
bytes[i] = binaryStr.charCodeAt(i)
|
|
1220
|
-
}
|
|
1221
|
-
const pcm16 = new Int16Array(bytes.buffer)
|
|
1222
|
-
|
|
1223
|
-
// Convert to Float32 for Web Audio
|
|
1224
|
-
const float32 = new Float32Array(pcm16.length)
|
|
1225
|
-
for (let i = 0; i < pcm16.length; i++) {
|
|
1226
|
-
float32[i] = pcm16[i] / 32768.0
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
// Create audio buffer and schedule playback
|
|
1230
|
-
const buffer = ctx.createBuffer(1, float32.length, SAMPLE_RATE)
|
|
1231
|
-
buffer.getChannelData(0).set(float32)
|
|
1232
|
-
|
|
1233
|
-
const source = ctx.createBufferSource()
|
|
1234
|
-
source.buffer = buffer
|
|
1235
|
-
source.connect(ctx.destination)
|
|
1236
|
-
|
|
1237
|
-
// Schedule seamless playback
|
|
1238
|
-
const now = ctx.currentTime
|
|
1239
|
-
const startTime = Math.max(now, voiceState.nextPlayTime)
|
|
1240
|
-
source.start(startTime)
|
|
1241
|
-
voiceState.nextPlayTime = startTime + buffer.duration
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
function stopVoice() {
|
|
1245
|
-
voiceState.active = false
|
|
1246
|
-
|
|
1247
|
-
// Close WebSocket
|
|
1248
|
-
if (voiceState.ws) {
|
|
1249
|
-
try { voiceState.ws.close() } catch {}
|
|
1250
|
-
voiceState.ws = null
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// Stop microphone
|
|
1254
|
-
if (voiceState.micStream) {
|
|
1255
|
-
voiceState.micStream.getTracks().forEach(t => t.stop())
|
|
1256
|
-
voiceState.micStream = null
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
// Disconnect audio processor
|
|
1260
|
-
if (voiceState.scriptProcessor) {
|
|
1261
|
-
try { voiceState.scriptProcessor.disconnect() } catch {}
|
|
1262
|
-
voiceState.scriptProcessor = null
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
// Close audio context
|
|
1266
|
-
if (voiceState.audioCtx) {
|
|
1267
|
-
try { voiceState.audioCtx.close() } catch {}
|
|
1268
|
-
voiceState.audioCtx = null
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
voiceState.nextPlayTime = 0
|
|
1272
|
-
|
|
1273
|
-
// Reset UI
|
|
1274
|
-
const btn = document.getElementById('voice-btn')
|
|
1275
|
-
if (btn) {
|
|
1276
|
-
btn.classList.remove('active', 'connecting')
|
|
1277
|
-
btn.querySelector('.voice-icon-mic').style.display = 'block'
|
|
1278
|
-
btn.querySelector('.voice-icon-stop').style.display = 'none'
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
const overlay = document.getElementById('voice-overlay')
|
|
1282
|
-
if (overlay) {
|
|
1283
|
-
overlay.style.display = 'none'
|
|
1284
|
-
overlay.classList.remove('listening', 'speaking')
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
</script>
|
|
1288
|
-
</body>
|
|
1289
|
-
</html>`;
|
|
1290
|
-
}
|
|
1291
|
-
function renderCronRequestsPage(requests) {
|
|
1292
|
-
const pendingCount = requests.filter(r => r.status === 'pending').length;
|
|
1293
|
-
const approvedCount = requests.filter(r => r.status === 'approved').length;
|
|
1294
|
-
const rejectedCount = requests.filter(r => r.status === 'rejected').length;
|
|
1295
|
-
const requestsHtml = requests.length === 0
|
|
1296
|
-
? `<div class="empty-state">No cron requests found.</div>`
|
|
1297
|
-
: requests.map(r => {
|
|
1298
|
-
const statusColor = r.status === 'pending' ? 'var(--accent)' :
|
|
1299
|
-
r.status === 'approved' ? 'var(--green)' : 'var(--red)';
|
|
1300
|
-
const actionButtons = r.status === 'pending'
|
|
1301
|
-
? `<div class="request-actions">
|
|
1302
|
-
<button class="action-btn approve-btn"
|
|
1303
|
-
hx-post="/approve-request?id=${r.id}"
|
|
1304
|
-
hx-confirm="Approve this cron job request?"
|
|
1305
|
-
hx-target="#action-status"
|
|
1306
|
-
hx-swap="innerHTML"
|
|
1307
|
-
title="Approve request">✓</button>
|
|
1308
|
-
<button class="action-btn reject-btn"
|
|
1309
|
-
hx-post="/reject-request?id=${r.id}"
|
|
1310
|
-
hx-confirm="Reject this cron job request?"
|
|
1311
|
-
hx-target="#action-status"
|
|
1312
|
-
hx-swap="innerHTML"
|
|
1313
|
-
title="Reject request">✗</button>
|
|
1314
|
-
</div>`
|
|
1315
|
-
: '';
|
|
1316
|
-
return `<div class="request-card" style="border-left: 4px solid ${statusColor}">
|
|
1317
|
-
<div class="request-header">
|
|
1318
|
-
<div class="request-title">
|
|
1319
|
-
<strong>${escapeHtml(r.name)}</strong>
|
|
1320
|
-
<span class="request-status" style="color: ${statusColor}">${r.status.toUpperCase()}</span>
|
|
1321
|
-
</div>
|
|
1322
|
-
<div class="request-meta">
|
|
1323
|
-
<span class="request-coworker">👤 ${escapeHtml(r.session_name)}</span>
|
|
1324
|
-
<span class="request-time">🕒 ${formatFullTime(r.requested_at)}</span>
|
|
1325
|
-
</div>
|
|
1326
|
-
</div>
|
|
1327
|
-
<div class="request-details">
|
|
1328
|
-
<div class="request-schedule">
|
|
1329
|
-
<strong>Schedule:</strong> <code>${escapeHtml(r.schedule)}</code>
|
|
1330
|
-
${r.timezone ? `<span class="timezone">(TZ: ${escapeHtml(r.timezone)})</span>` : ''}
|
|
1331
|
-
</div>
|
|
1332
|
-
<div class="request-message">
|
|
1333
|
-
<strong>Message:</strong>
|
|
1334
|
-
<div class="message-content markdown-body" data-markdown>${escapeHtml(r.message)}</div>
|
|
1335
|
-
</div>
|
|
1336
|
-
${r.reviewed_at && r.reviewed_by ? `
|
|
1337
|
-
<div class="request-review">
|
|
1338
|
-
<strong>Reviewed by ${escapeHtml(r.reviewed_by)} on ${formatFullTime(r.reviewed_at)}</strong>
|
|
1339
|
-
${r.reviewer_notes ? `<div class="review-notes">${escapeHtml(r.reviewer_notes)}</div>` : ''}
|
|
1340
|
-
</div>
|
|
1341
|
-
` : ''}
|
|
1342
|
-
</div>
|
|
1343
|
-
${actionButtons}
|
|
1344
|
-
</div>`;
|
|
1345
|
-
}).join('\n');
|
|
1346
|
-
return `<!DOCTYPE html>
|
|
1347
|
-
<html lang="en">
|
|
1348
|
-
<head>
|
|
1349
|
-
<meta charset="UTF-8">
|
|
1350
|
-
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
1351
|
-
<title>Cron Requests — agent-office</title>
|
|
1352
|
-
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
|
1353
|
-
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
|
1354
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.5.1/github-markdown-light.min.css" media="(prefers-color-scheme: light)">
|
|
1355
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.5.1/github-markdown-dark.min.css" media="(prefers-color-scheme: dark)">
|
|
1356
|
-
<style>
|
|
1357
|
-
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1358
|
-
|
|
1359
|
-
:root {
|
|
1360
|
-
--bg: #0f1117;
|
|
1361
|
-
--surface: #1a1d27;
|
|
1362
|
-
--surface2: #22263a;
|
|
1363
|
-
--border: #2e3248;
|
|
1364
|
-
--accent: #6c8eff;
|
|
1365
|
-
--accent-dim: #3d52a0;
|
|
1366
|
-
--text: #e2e8f0;
|
|
1367
|
-
--text-dim: #8892a4;
|
|
1368
|
-
--green: #6bffb8;
|
|
1369
|
-
--red: #ff6b6b;
|
|
1370
|
-
--radius: 12px;
|
|
1371
|
-
--radius-sm: 6px;
|
|
1372
|
-
--header-h: 56px;
|
|
1373
|
-
font-size: 16px;
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
html, body {
|
|
1377
|
-
height: 100%;
|
|
1378
|
-
background: var(--bg);
|
|
1379
|
-
color: var(--text);
|
|
1380
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
1381
|
-
overflow: hidden;
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
.app {
|
|
1385
|
-
display: flex;
|
|
1386
|
-
flex-direction: column;
|
|
1387
|
-
height: 100dvh;
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
/* Header */
|
|
1391
|
-
.header {
|
|
1392
|
-
flex-shrink: 0;
|
|
1393
|
-
height: var(--header-h);
|
|
1394
|
-
background: var(--surface);
|
|
1395
|
-
border-bottom: 1px solid var(--border);
|
|
1396
|
-
display: flex;
|
|
1397
|
-
align-items: center;
|
|
1398
|
-
padding: 0 16px;
|
|
1399
|
-
gap: 12px;
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
.header-title {
|
|
1403
|
-
font-weight: 600;
|
|
1404
|
-
font-size: 18px;
|
|
1405
|
-
color: var(--accent);
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
.header-stats {
|
|
1409
|
-
margin-left: auto;
|
|
1410
|
-
display: flex;
|
|
1411
|
-
gap: 16px;
|
|
1412
|
-
font-size: 14px;
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
.stat-item {
|
|
1416
|
-
display: flex;
|
|
1417
|
-
align-items: center;
|
|
1418
|
-
gap: 4px;
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
.stat-count {
|
|
1422
|
-
font-weight: 600;
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
.back-link {
|
|
1426
|
-
color: var(--text-dim);
|
|
1427
|
-
text-decoration: none;
|
|
1428
|
-
font-size: 16px;
|
|
1429
|
-
transition: color 0.15s;
|
|
1430
|
-
}
|
|
1431
|
-
.back-link:hover { color: var(--accent); }
|
|
1432
|
-
|
|
1433
|
-
/* Content */
|
|
1434
|
-
.content {
|
|
1435
|
-
flex: 1;
|
|
1436
|
-
overflow-y: auto;
|
|
1437
|
-
padding: 20px;
|
|
1438
|
-
display: flex;
|
|
1439
|
-
flex-direction: column;
|
|
1440
|
-
gap: 16px;
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
.filter-tabs {
|
|
1444
|
-
display: flex;
|
|
1445
|
-
gap: 8px;
|
|
1446
|
-
margin-bottom: 16px;
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
.filter-tab {
|
|
1450
|
-
padding: 8px 16px;
|
|
1451
|
-
background: var(--surface2);
|
|
1452
|
-
border: 1px solid var(--border);
|
|
1453
|
-
border-radius: var(--radius-sm);
|
|
1454
|
-
color: var(--text-dim);
|
|
1455
|
-
text-decoration: none;
|
|
1456
|
-
font-size: 14px;
|
|
1457
|
-
transition: all 0.15s;
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
.filter-tab:hover {
|
|
1461
|
-
border-color: var(--accent-dim);
|
|
1462
|
-
color: var(--accent);
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
.filter-tab.active {
|
|
1466
|
-
background: var(--accent);
|
|
1467
|
-
color: #fff;
|
|
1468
|
-
border-color: var(--accent);
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
.empty-state {
|
|
1472
|
-
text-align: center;
|
|
1473
|
-
color: var(--text-dim);
|
|
1474
|
-
font-size: 16px;
|
|
1475
|
-
padding: 48px;
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
/* Request Cards */
|
|
1479
|
-
.request-card {
|
|
1480
|
-
background: var(--surface);
|
|
1481
|
-
border: 1px solid var(--border);
|
|
1482
|
-
border-radius: var(--radius);
|
|
1483
|
-
padding: 16px;
|
|
1484
|
-
margin-bottom: 12px;
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
.request-header {
|
|
1488
|
-
display: flex;
|
|
1489
|
-
justify-content: space-between;
|
|
1490
|
-
align-items: flex-start;
|
|
1491
|
-
margin-bottom: 12px;
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
.request-title {
|
|
1495
|
-
display: flex;
|
|
1496
|
-
align-items: center;
|
|
1497
|
-
gap: 8px;
|
|
1498
|
-
font-size: 16px;
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
.request-status {
|
|
1502
|
-
font-size: 12px;
|
|
1503
|
-
font-weight: 600;
|
|
1504
|
-
padding: 2px 8px;
|
|
1505
|
-
border-radius: var(--radius-sm);
|
|
1506
|
-
background: rgba(255, 255, 255, 0.1);
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
.request-meta {
|
|
1510
|
-
display: flex;
|
|
1511
|
-
flex-direction: column;
|
|
1512
|
-
align-items: flex-end;
|
|
1513
|
-
gap: 2px;
|
|
1514
|
-
font-size: 12px;
|
|
1515
|
-
color: var(--text-dim);
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
.request-details {
|
|
1519
|
-
display: flex;
|
|
1520
|
-
flex-direction: column;
|
|
1521
|
-
gap: 8px;
|
|
1522
|
-
font-size: 14px;
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
.request-schedule {
|
|
1526
|
-
display: flex;
|
|
1527
|
-
align-items: center;
|
|
1528
|
-
gap: 8px;
|
|
1529
|
-
flex-wrap: wrap;
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
.request-schedule code {
|
|
1533
|
-
background: var(--surface2);
|
|
1534
|
-
padding: 2px 6px;
|
|
1535
|
-
border-radius: var(--radius-sm);
|
|
1536
|
-
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
1537
|
-
font-size: 13px;
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
.timezone {
|
|
1541
|
-
color: var(--text-dim);
|
|
1542
|
-
font-size: 12px;
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
.request-message {
|
|
1546
|
-
margin-top: 4px;
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
.message-content {
|
|
1550
|
-
background: var(--surface2);
|
|
1551
|
-
border: 1px solid var(--border);
|
|
1552
|
-
border-radius: var(--radius-sm);
|
|
1553
|
-
padding: 8px 12px;
|
|
1554
|
-
margin-top: 4px;
|
|
1555
|
-
font-size: 13px;
|
|
1556
|
-
line-height: 1.4;
|
|
1557
|
-
white-space: pre-wrap;
|
|
1558
|
-
word-break: break-word;
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
.request-review {
|
|
1562
|
-
margin-top: 8px;
|
|
1563
|
-
padding-top: 8px;
|
|
1564
|
-
border-top: 1px solid var(--border);
|
|
1565
|
-
font-size: 13px;
|
|
1566
|
-
color: var(--text-dim);
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
.review-notes {
|
|
1570
|
-
margin-top: 4px;
|
|
1571
|
-
background: rgba(255, 255, 255, 0.05);
|
|
1572
|
-
padding: 6px 10px;
|
|
1573
|
-
border-radius: var(--radius-sm);
|
|
1574
|
-
border-left: 3px solid var(--accent);
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
.request-actions {
|
|
1578
|
-
display: flex;
|
|
1579
|
-
gap: 8px;
|
|
1580
|
-
margin-top: 12px;
|
|
1581
|
-
padding-top: 12px;
|
|
1582
|
-
border-top: 1px solid var(--border);
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
.action-btn {
|
|
1586
|
-
padding: 6px 12px;
|
|
1587
|
-
border: 1px solid var(--border);
|
|
1588
|
-
border-radius: var(--radius-sm);
|
|
1589
|
-
cursor: pointer;
|
|
1590
|
-
font-size: 14px;
|
|
1591
|
-
font-weight: 600;
|
|
1592
|
-
transition: all 0.15s;
|
|
1593
|
-
display: flex;
|
|
1594
|
-
align-items: center;
|
|
1595
|
-
justify-content: center;
|
|
1596
|
-
min-width: 32px;
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
.approve-btn {
|
|
1600
|
-
background: rgba(107, 255, 184, 0.1);
|
|
1601
|
-
border-color: var(--green);
|
|
1602
|
-
color: var(--green);
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
.approve-btn:hover {
|
|
1606
|
-
background: var(--green);
|
|
1607
|
-
color: #fff;
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
.reject-btn {
|
|
1611
|
-
background: rgba(255, 107, 107, 0.1);
|
|
1612
|
-
border-color: var(--red);
|
|
1613
|
-
color: var(--red);
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
|
-
.reject-btn:hover {
|
|
1617
|
-
background: var(--red);
|
|
1618
|
-
color: #fff;
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
.action-btn.htmx-request {
|
|
1622
|
-
opacity: 0.6;
|
|
1623
|
-
pointer-events: none;
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
/* Status messages */
|
|
1627
|
-
#action-status {
|
|
1628
|
-
position: fixed;
|
|
1629
|
-
bottom: 20px;
|
|
1630
|
-
left: 50%;
|
|
1631
|
-
transform: translateX(-50%);
|
|
1632
|
-
background: var(--surface);
|
|
1633
|
-
border: 1px solid var(--border);
|
|
1634
|
-
border-radius: var(--radius);
|
|
1635
|
-
padding: 12px 20px;
|
|
1636
|
-
font-size: 14px;
|
|
1637
|
-
pointer-events: none;
|
|
1638
|
-
opacity: 0;
|
|
1639
|
-
transition: opacity 0.3s;
|
|
1640
|
-
z-index: 1000;
|
|
1641
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
1642
|
-
}
|
|
1643
|
-
|
|
1644
|
-
#action-status.visible {
|
|
1645
|
-
opacity: 1;
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
#action-status.success {
|
|
1649
|
-
border-color: var(--green);
|
|
1650
|
-
color: var(--green);
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
#action-status.error {
|
|
1654
|
-
border-color: var(--red);
|
|
1655
|
-
color: var(--red);
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
/* Markdown styling */
|
|
1659
|
-
.markdown-body {
|
|
1660
|
-
background: transparent;
|
|
1661
|
-
color: inherit;
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
.markdown-body p { margin: 0 0 8px 0; }
|
|
1665
|
-
.markdown-body p:last-child { margin-bottom: 0; }
|
|
1666
|
-
.markdown-body pre {
|
|
1667
|
-
background: rgba(0,0,0,0.3);
|
|
1668
|
-
border-radius: 6px;
|
|
1669
|
-
padding: 8px 12px;
|
|
1670
|
-
overflow-x: auto;
|
|
1671
|
-
margin: 8px 0;
|
|
1672
|
-
}
|
|
1673
|
-
.markdown-body code {
|
|
1674
|
-
background: rgba(0,0,0,0.2);
|
|
1675
|
-
padding: 2px 5px;
|
|
1676
|
-
border-radius: 3px;
|
|
1677
|
-
font-size: 12px;
|
|
1678
|
-
}
|
|
1679
|
-
.markdown-body pre code {
|
|
1680
|
-
background: transparent;
|
|
1681
|
-
padding: 0;
|
|
1682
|
-
}
|
|
1683
|
-
</style>
|
|
1684
|
-
</head>
|
|
1685
|
-
<body>
|
|
1686
|
-
<div class="app">
|
|
1687
|
-
<div class="header">
|
|
1688
|
-
<a href="/" class="back-link" title="Back to chat">←</a>
|
|
1689
|
-
<div class="header-title">Cron Requests</div>
|
|
1690
|
-
<div class="header-stats">
|
|
1691
|
-
<div class="stat-item">
|
|
1692
|
-
<span>⏳</span>
|
|
1693
|
-
<span class="stat-count">${pendingCount}</span>
|
|
1694
|
-
<span>pending</span>
|
|
1695
|
-
</div>
|
|
1696
|
-
<div class="stat-item">
|
|
1697
|
-
<span>✅</span>
|
|
1698
|
-
<span class="stat-count">${approvedCount}</span>
|
|
1699
|
-
<span>approved</span>
|
|
1700
|
-
</div>
|
|
1701
|
-
<div class="stat-item">
|
|
1702
|
-
<span>❌</span>
|
|
1703
|
-
<span class="stat-count">${rejectedCount}</span>
|
|
1704
|
-
<span>rejected</span>
|
|
1705
|
-
</div>
|
|
1706
|
-
</div>
|
|
1707
|
-
</div>
|
|
1708
|
-
|
|
1709
|
-
<div class="content">
|
|
1710
|
-
<div class="filter-tabs">
|
|
1711
|
-
<a href="/cron-requests" class="filter-tab">All</a>
|
|
1712
|
-
<a href="/cron-requests?status=pending" class="filter-tab">Pending</a>
|
|
1713
|
-
<a href="/cron-requests?status=approved" class="filter-tab">Approved</a>
|
|
1714
|
-
<a href="/cron-requests?status=rejected" class="filter-tab">Rejected</a>
|
|
1715
|
-
</div>
|
|
1716
|
-
|
|
1717
|
-
${requestsHtml}
|
|
1718
|
-
</div>
|
|
1719
|
-
|
|
1720
|
-
<div id="action-status"></div>
|
|
1721
|
-
</div>
|
|
1722
|
-
|
|
1723
|
-
<script>
|
|
1724
|
-
// Set active filter tab based on URL
|
|
1725
|
-
function setActiveTab() {
|
|
1726
|
-
const urlParams = new URLSearchParams(window.location.search)
|
|
1727
|
-
const status = urlParams.get('status') || 'all'
|
|
1728
|
-
document.querySelectorAll('.filter-tab').forEach(tab => {
|
|
1729
|
-
const href = tab.getAttribute('href')
|
|
1730
|
-
const tabStatus = href === '/cron-requests' ? 'all' : href.split('=')[1]
|
|
1731
|
-
if (tabStatus === status) {
|
|
1732
|
-
tab.classList.add('active')
|
|
1733
|
-
} else {
|
|
1734
|
-
tab.classList.remove('active')
|
|
1735
|
-
}
|
|
1736
|
-
})
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
// Render markdown in request messages
|
|
1740
|
-
function renderMarkdown() {
|
|
1741
|
-
if (typeof marked === 'undefined') return
|
|
1742
|
-
document.querySelectorAll('.message-content').forEach(el => {
|
|
1743
|
-
if (!el.hasAttribute('data-rendered')) {
|
|
1744
|
-
el.innerHTML = marked.parse(el.textContent || '')
|
|
1745
|
-
el.setAttribute('data-rendered', 'true')
|
|
1746
|
-
}
|
|
1747
|
-
})
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
// Show status messages
|
|
1751
|
-
function showStatus(message, type = 'success') {
|
|
1752
|
-
const el = document.getElementById('action-status')
|
|
1753
|
-
if (!el) return
|
|
1754
|
-
el.textContent = message
|
|
1755
|
-
el.className = type
|
|
1756
|
-
el.classList.add('visible')
|
|
1757
|
-
clearTimeout(el._hideTimer)
|
|
1758
|
-
el._hideTimer = setTimeout(() => el.classList.remove('visible'), 4000)
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
// Initial setup
|
|
1762
|
-
setActiveTab()
|
|
1763
|
-
renderMarkdown()
|
|
1764
|
-
|
|
1765
|
-
// HTMX event handlers
|
|
1766
|
-
document.addEventListener('htmx:afterRequest', (e) => {
|
|
1767
|
-
const xhr = e.detail.xhr
|
|
1768
|
-
if (xhr.status >= 200 && xhr.status < 300) {
|
|
1769
|
-
// Success - refresh the page
|
|
1770
|
-
setTimeout(() => window.location.reload(), 1000)
|
|
1771
|
-
showStatus('Action completed successfully')
|
|
1772
|
-
} else {
|
|
1773
|
-
// Error
|
|
1774
|
-
try {
|
|
1775
|
-
const response = JSON.parse(xhr.responseText)
|
|
1776
|
-
showStatus('Error: ' + (response.error || 'Unknown error'), 'error')
|
|
1777
|
-
} catch {
|
|
1778
|
-
showStatus('Error: Request failed', 'error')
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
})
|
|
1782
|
-
</script>
|
|
1783
|
-
</body>
|
|
1784
|
-
</html>`;
|
|
1785
|
-
}
|
|
1786
|
-
// ── Express app ───────────────────────────────────────────────────────────────
|
|
1787
|
-
export async function appCoworkerChatWeb(options) {
|
|
1788
|
-
const { url: agentUrl, password, host, port: portStr, xaiKey } = options;
|
|
1789
|
-
const voiceEnabled = !!xaiKey;
|
|
1790
|
-
const port = parseInt(portStr, 10);
|
|
1791
|
-
if (isNaN(port) || port < 1 || port > 65535) {
|
|
1792
|
-
console.error(`Error: invalid port "${portStr}"`);
|
|
1793
|
-
process.exit(1);
|
|
1794
|
-
}
|
|
1795
|
-
try {
|
|
1796
|
-
new URL(agentUrl);
|
|
1797
|
-
}
|
|
1798
|
-
catch {
|
|
1799
|
-
console.error(`Error: invalid --url "${agentUrl}"`);
|
|
1800
|
-
process.exit(1);
|
|
1801
|
-
}
|
|
1802
|
-
// Resolve human name once at startup
|
|
1803
|
-
let humanName = "Human";
|
|
1804
|
-
try {
|
|
1805
|
-
humanName = await getHumanName(agentUrl, password);
|
|
1806
|
-
}
|
|
1807
|
-
catch (err) {
|
|
1808
|
-
console.error(`Warning: could not fetch human name from ${agentUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1809
|
-
console.error("Check that agent-office serve is running and --password is correct.");
|
|
1810
|
-
}
|
|
1811
|
-
console.log(`Communicator: chatting as "${humanName}"`);
|
|
1812
|
-
if (voiceEnabled) {
|
|
1813
|
-
console.log(`Voice chat enabled (xAI API key configured)`);
|
|
1814
|
-
}
|
|
1815
|
-
const app = express();
|
|
1816
|
-
app.use(express.urlencoded({ extended: false }));
|
|
1817
|
-
app.use(express.json());
|
|
1818
|
-
// ── GET /coworkers — list of coworkers (HTMX) ───────────────────────────────
|
|
1819
|
-
app.get("/coworkers", async (_req, res) => {
|
|
1820
|
-
res.setHeader("Content-Type", "application/json");
|
|
1821
|
-
try {
|
|
1822
|
-
const response = await fetch(`${agentUrl}/coworkers`, {
|
|
1823
|
-
headers: { "Authorization": `Bearer ${password}` },
|
|
1824
|
-
});
|
|
1825
|
-
if (!response.ok) {
|
|
1826
|
-
res.json([{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]);
|
|
1827
|
-
return;
|
|
1828
|
-
}
|
|
1829
|
-
const coworkers = await response.json();
|
|
1830
|
-
res.json(coworkers);
|
|
1831
|
-
}
|
|
1832
|
-
catch {
|
|
1833
|
-
res.json([{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]);
|
|
1834
|
-
}
|
|
1835
|
-
});
|
|
1836
|
-
// ── GET /dropdown — dropdown fragment for HTMX polling ────────────────────
|
|
1837
|
-
app.get("/dropdown", async (req, res) => {
|
|
1838
|
-
const coworker = req.query.coworker;
|
|
1839
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1840
|
-
try {
|
|
1841
|
-
const response = await fetch(`${agentUrl}/coworkers`, {
|
|
1842
|
-
headers: { "Authorization": `Bearer ${password}` },
|
|
1843
|
-
});
|
|
1844
|
-
if (!response.ok) {
|
|
1845
|
-
res.send(renderDropdown(coworker ?? null, [{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]));
|
|
1846
|
-
return;
|
|
1847
|
-
}
|
|
1848
|
-
const coworkers = await response.json();
|
|
1849
|
-
res.send(renderDropdown(coworker ?? null, coworkers));
|
|
1850
|
-
}
|
|
1851
|
-
catch {
|
|
1852
|
-
res.send(renderDropdown(coworker ?? null, [{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]));
|
|
1853
|
-
}
|
|
1854
|
-
});
|
|
1855
|
-
// ── GET /cron-requests — cron requests page ──────────────────────────────
|
|
1856
|
-
app.get("/cron-requests", async (req, res) => {
|
|
1857
|
-
try {
|
|
1858
|
-
const status = req.query.status;
|
|
1859
|
-
const requests = await fetchCronRequests(agentUrl, password, status);
|
|
1860
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1861
|
-
res.send(renderCronRequestsPage(requests));
|
|
1862
|
-
}
|
|
1863
|
-
catch (err) {
|
|
1864
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1865
|
-
res.status(502).send(`<pre>Error connecting to agent-office: ${escapeHtml(msg)}</pre>`);
|
|
1866
|
-
}
|
|
1867
|
-
});
|
|
1868
|
-
// ── POST /approve-request — approve a cron request ─────────────────────────
|
|
1869
|
-
app.post("/approve-request", async (req, res) => {
|
|
1870
|
-
const id = req.query.id;
|
|
1871
|
-
if (!id) {
|
|
1872
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1873
|
-
res.send(`<span style="color:var(--red)">Error: Request ID required</span>`);
|
|
1874
|
-
return;
|
|
1875
|
-
}
|
|
1876
|
-
try {
|
|
1877
|
-
await approveCronRequest(agentUrl, password, parseInt(id, 10));
|
|
1878
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1879
|
-
res.send(`<span style="color:var(--green)">✓ Request approved successfully</span>`);
|
|
1880
|
-
}
|
|
1881
|
-
catch (err) {
|
|
1882
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1883
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1884
|
-
res.send(`<span style="color:var(--red)">✗ Approval failed: ${escapeHtml(msg)}</span>`);
|
|
1885
|
-
}
|
|
1886
|
-
});
|
|
1887
|
-
// ── POST /reject-request — reject a cron request ───────────────────────────
|
|
1888
|
-
app.post("/reject-request", async (req, res) => {
|
|
1889
|
-
const id = req.query.id;
|
|
1890
|
-
if (!id) {
|
|
1891
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1892
|
-
res.send(`<span style="color:var(--red)">Error: Request ID required</span>`);
|
|
1893
|
-
return;
|
|
1894
|
-
}
|
|
1895
|
-
try {
|
|
1896
|
-
await rejectCronRequest(agentUrl, password, parseInt(id, 10));
|
|
1897
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1898
|
-
res.send(`<span style="color:var(--green)">✓ Request rejected successfully</span>`);
|
|
1899
|
-
}
|
|
1900
|
-
catch (err) {
|
|
1901
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1902
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1903
|
-
res.send(`<span style="color:var(--red)">✗ Rejection failed: ${escapeHtml(msg)}</span>`);
|
|
1904
|
-
}
|
|
1905
|
-
});
|
|
1906
|
-
// ── GET /voice/config — whether voice is enabled ──────────────────────────
|
|
1907
|
-
app.get("/voice/config", (_req, res) => {
|
|
1908
|
-
res.json({ enabled: voiceEnabled });
|
|
1909
|
-
});
|
|
1910
|
-
// ── POST /voice/session — fetch ephemeral token from xAI ─────────────────
|
|
1911
|
-
app.post("/voice/session", async (req, res) => {
|
|
1912
|
-
if (!voiceEnabled || !xaiKey) {
|
|
1913
|
-
res.status(403).json({ error: "Voice is not enabled" });
|
|
1914
|
-
return;
|
|
1915
|
-
}
|
|
1916
|
-
const { coworker } = req.body;
|
|
1917
|
-
if (!coworker) {
|
|
1918
|
-
res.status(400).json({ error: "coworker is required" });
|
|
1919
|
-
return;
|
|
1920
|
-
}
|
|
1921
|
-
try {
|
|
1922
|
-
// Fetch the coworker's status for context
|
|
1923
|
-
const status = await fetchCoworkerStatus(agentUrl, password, coworker);
|
|
1924
|
-
// Get ephemeral token from xAI
|
|
1925
|
-
const tokenRes = await fetch("https://api.x.ai/v1/realtime/client_secrets", {
|
|
1926
|
-
method: "POST",
|
|
1927
|
-
headers: {
|
|
1928
|
-
"Authorization": `Bearer ${xaiKey}`,
|
|
1929
|
-
"Content-Type": "application/json",
|
|
1930
|
-
},
|
|
1931
|
-
body: JSON.stringify({ expires_after: { seconds: 300 } }),
|
|
1932
|
-
});
|
|
1933
|
-
if (!tokenRes.ok) {
|
|
1934
|
-
const errBody = await tokenRes.json().catch(() => ({}));
|
|
1935
|
-
res.status(502).json({ error: `xAI API error: ${errBody.error ?? `HTTP ${tokenRes.status}`}` });
|
|
1936
|
-
return;
|
|
1937
|
-
}
|
|
1938
|
-
// xAI returns { value: string, expires_at: number } at the top level
|
|
1939
|
-
const tokenData = await tokenRes.json();
|
|
1940
|
-
const token = tokenData.value;
|
|
1941
|
-
if (!token) {
|
|
1942
|
-
console.error("Voice session: unexpected xAI response shape:", JSON.stringify(tokenData));
|
|
1943
|
-
res.status(502).json({ error: "No ephemeral token in xAI response" });
|
|
1944
|
-
return;
|
|
1945
|
-
}
|
|
1946
|
-
// Build voice instructions based on the coworker
|
|
1947
|
-
const instructions = [
|
|
1948
|
-
`You are ${escapeHtml(coworker)}, an AI coworker in the agent office.`,
|
|
1949
|
-
`Your token code is: ${coworker}@${agentUrl}`,
|
|
1950
|
-
status ? `Your current status is: "${status}".` : "",
|
|
1951
|
-
`You are having a voice conversation with your human manager ${humanName}.`,
|
|
1952
|
-
`Be helpful, collaborative, and keep your responses concise since this is a voice conversation.`,
|
|
1953
|
-
`You can discuss work, answer questions, and collaborate on tasks.`,
|
|
1954
|
-
``,
|
|
1955
|
-
`You have access to the agent-office CLI tool which can:`,
|
|
1956
|
-
`- Create and manage AI coworker sessions`,
|
|
1957
|
-
`- Send messages between coworkers`,
|
|
1958
|
-
`- Set status messages for visibility`,
|
|
1959
|
-
`- Schedule cron jobs for recurring tasks`,
|
|
1960
|
-
`- Run a web chat interface for human interaction`,
|
|
1961
|
-
`- Manage task boards with kanban-style workflows`,
|
|
1962
|
-
`- Send email notifications for unread messages`,
|
|
1963
|
-
``,
|
|
1964
|
-
`You have access to coding tools that you can use when the human asks you to look at, create, or modify files, or run commands:`,
|
|
1965
|
-
`- read: Read a file from the filesystem. Use this to examine source code, config files, etc.`,
|
|
1966
|
-
`- write: Write content to a file, creating or overwriting it.`,
|
|
1967
|
-
`- edit: Edit a file by finding and replacing an exact string.`,
|
|
1968
|
-
`- bash: Execute a shell command and get the output.`,
|
|
1969
|
-
``,
|
|
1970
|
-
`When using tools, briefly tell the human what you're doing before calling the tool.`,
|
|
1971
|
-
`After getting tool results, summarize the key information verbally rather than reading everything.`,
|
|
1972
|
-
`The working directory is: ${process.cwd()}`,
|
|
1973
|
-
].filter(Boolean).join("\n");
|
|
1974
|
-
res.json({
|
|
1975
|
-
token,
|
|
1976
|
-
instructions,
|
|
1977
|
-
coworker,
|
|
1978
|
-
});
|
|
1979
|
-
}
|
|
1980
|
-
catch (err) {
|
|
1981
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1982
|
-
res.status(502).json({ error: `Failed to create voice session: ${msg}` });
|
|
1983
|
-
}
|
|
1984
|
-
});
|
|
1985
|
-
// ── POST /voice/tool — execute a tool call server-side ────────────────────
|
|
1986
|
-
app.post("/voice/tool", async (req, res) => {
|
|
1987
|
-
const { name, arguments: argsStr } = req.body;
|
|
1988
|
-
if (!name || typeof name !== "string") {
|
|
1989
|
-
res.status(400).json({ error: "name is required" });
|
|
1990
|
-
return;
|
|
1991
|
-
}
|
|
1992
|
-
let args;
|
|
1993
|
-
try {
|
|
1994
|
-
args = typeof argsStr === "string" ? JSON.parse(argsStr) : (argsStr ?? {});
|
|
1995
|
-
}
|
|
1996
|
-
catch {
|
|
1997
|
-
res.status(400).json({ error: "Invalid arguments JSON" });
|
|
1998
|
-
return;
|
|
1999
|
-
}
|
|
2000
|
-
try {
|
|
2001
|
-
let result;
|
|
2002
|
-
switch (name) {
|
|
2003
|
-
case "read": {
|
|
2004
|
-
const filePath = String(args.path ?? "");
|
|
2005
|
-
if (!filePath) {
|
|
2006
|
-
res.json({ output: "Error: path is required" });
|
|
2007
|
-
return;
|
|
2008
|
-
}
|
|
2009
|
-
const content = await readFile(filePath, "utf-8");
|
|
2010
|
-
const lines = content.split("\n");
|
|
2011
|
-
const offset = Math.max(1, Number(args.offset) || 1);
|
|
2012
|
-
const limit = Math.min(2000, Number(args.limit) || 200);
|
|
2013
|
-
const sliced = lines.slice(offset - 1, offset - 1 + limit);
|
|
2014
|
-
result = sliced.map((line, i) => `${offset + i}: ${line}`).join("\n");
|
|
2015
|
-
if (lines.length > offset - 1 + limit) {
|
|
2016
|
-
result += `\n... (${lines.length} total lines)`;
|
|
2017
|
-
}
|
|
2018
|
-
break;
|
|
2019
|
-
}
|
|
2020
|
-
case "write": {
|
|
2021
|
-
const filePath = String(args.path ?? "");
|
|
2022
|
-
const content = String(args.content ?? "");
|
|
2023
|
-
if (!filePath) {
|
|
2024
|
-
res.json({ output: "Error: path is required" });
|
|
2025
|
-
return;
|
|
2026
|
-
}
|
|
2027
|
-
await mkdir(dirname(filePath), { recursive: true });
|
|
2028
|
-
await writeFile(filePath, content, "utf-8");
|
|
2029
|
-
result = `Written ${content.length} bytes to ${filePath}`;
|
|
2030
|
-
break;
|
|
2031
|
-
}
|
|
2032
|
-
case "edit": {
|
|
2033
|
-
const filePath = String(args.path ?? "");
|
|
2034
|
-
const oldStr = String(args.oldText ?? "");
|
|
2035
|
-
const newStr = String(args.newText ?? "");
|
|
2036
|
-
if (!filePath) {
|
|
2037
|
-
res.json({ output: "Error: path is required" });
|
|
2038
|
-
return;
|
|
2039
|
-
}
|
|
2040
|
-
if (!oldStr) {
|
|
2041
|
-
res.json({ output: "Error: oldText is required" });
|
|
2042
|
-
return;
|
|
2043
|
-
}
|
|
2044
|
-
const fileContent = await readFile(filePath, "utf-8");
|
|
2045
|
-
const idx = fileContent.indexOf(oldStr);
|
|
2046
|
-
if (idx === -1) {
|
|
2047
|
-
result = "Error: oldText not found in file";
|
|
2048
|
-
}
|
|
2049
|
-
else if (fileContent.indexOf(oldStr, idx + 1) !== -1) {
|
|
2050
|
-
result = "Error: oldText found multiple times. Provide more context to make it unique.";
|
|
2051
|
-
}
|
|
2052
|
-
else {
|
|
2053
|
-
const edited = fileContent.slice(0, idx) + newStr + fileContent.slice(idx + oldStr.length);
|
|
2054
|
-
await writeFile(filePath, edited, "utf-8");
|
|
2055
|
-
result = `Edit applied to ${filePath}`;
|
|
2056
|
-
}
|
|
2057
|
-
break;
|
|
2058
|
-
}
|
|
2059
|
-
case "bash": {
|
|
2060
|
-
const command = String(args.command ?? "");
|
|
2061
|
-
if (!command) {
|
|
2062
|
-
res.json({ output: "Error: command is required" });
|
|
2063
|
-
return;
|
|
2064
|
-
}
|
|
2065
|
-
const timeoutSec = Math.min(120, Number(args.timeout) || 30);
|
|
2066
|
-
const timeout = timeoutSec * 1000;
|
|
2067
|
-
result = await new Promise((resolve) => {
|
|
2068
|
-
exec(command, { timeout, maxBuffer: 1024 * 1024, cwd: process.cwd(), env: process.env }, (err, stdout, stderr) => {
|
|
2069
|
-
const out = (stdout || "").trim();
|
|
2070
|
-
const errOut = (stderr || "").trim();
|
|
2071
|
-
if (err && err.killed) {
|
|
2072
|
-
resolve(`Command timed out after ${timeout}ms`);
|
|
2073
|
-
}
|
|
2074
|
-
else if (err) {
|
|
2075
|
-
resolve(`Exit code ${err.code ?? 1}\n${errOut}\n${out}`.trim());
|
|
2076
|
-
}
|
|
2077
|
-
else {
|
|
2078
|
-
const combined = errOut ? `${out}\n${errOut}` : out;
|
|
2079
|
-
resolve(combined || "(no output)");
|
|
2080
|
-
}
|
|
2081
|
-
});
|
|
2082
|
-
});
|
|
2083
|
-
// Truncate very long output for the voice context
|
|
2084
|
-
if (result.length > 4000) {
|
|
2085
|
-
result = result.slice(0, 4000) + "\n... (output truncated)";
|
|
2086
|
-
}
|
|
2087
|
-
break;
|
|
2088
|
-
}
|
|
2089
|
-
default:
|
|
2090
|
-
result = `Unknown tool: ${name}`;
|
|
2091
|
-
}
|
|
2092
|
-
res.json({ output: result });
|
|
2093
|
-
}
|
|
2094
|
-
catch (err) {
|
|
2095
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2096
|
-
res.json({ output: `Error: ${msg}` });
|
|
2097
|
-
}
|
|
2098
|
-
});
|
|
2099
|
-
// ── GET / — full page ────────────────────────────────────────────────────
|
|
2100
|
-
app.get("/", async (req, res) => {
|
|
2101
|
-
try {
|
|
2102
|
-
// Fetch coworkers with unread counts from main server
|
|
2103
|
-
const response = await fetch(`${agentUrl}/coworkers`, {
|
|
2104
|
-
headers: { "Authorization": `Bearer ${password}` },
|
|
2105
|
-
});
|
|
2106
|
-
let coworkers;
|
|
2107
|
-
if (response.ok) {
|
|
2108
|
-
coworkers = await response.json();
|
|
2109
|
-
}
|
|
2110
|
-
else {
|
|
2111
|
-
coworkers = [{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }];
|
|
2112
|
-
}
|
|
2113
|
-
const nonHumans = coworkers.filter(c => !c.isHuman);
|
|
2114
|
-
let coworker = req.query.coworker;
|
|
2115
|
-
// Default to first non-human coworker if none specified
|
|
2116
|
-
if (!coworker && nonHumans.length > 0) {
|
|
2117
|
-
coworker = nonHumans[0].name;
|
|
2118
|
-
}
|
|
2119
|
-
const msgs = coworker ? await fetchMessages(agentUrl, password, humanName, coworker) : [];
|
|
2120
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2121
|
-
res.send(renderPage(coworker ?? null, coworkers, msgs, humanName));
|
|
2122
|
-
}
|
|
2123
|
-
catch (err) {
|
|
2124
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2125
|
-
res.status(502).send(`<pre>Error connecting to agent-office: ${escapeHtml(msg)}</pre>`);
|
|
2126
|
-
}
|
|
2127
|
-
});
|
|
2128
|
-
// ── GET /messages — HTMX fragment (polled every 5s) ──────────────────────
|
|
2129
|
-
app.get("/messages", async (req, res) => {
|
|
2130
|
-
const coworker = req.query.coworker;
|
|
2131
|
-
if (!coworker) {
|
|
2132
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2133
|
-
res.send(`<div class="empty-state">Select a coworker to view messages.</div>`);
|
|
2134
|
-
return;
|
|
2135
|
-
}
|
|
2136
|
-
try {
|
|
2137
|
-
const msgs = await fetchMessages(agentUrl, password, humanName, coworker);
|
|
2138
|
-
// Mark any unread received messages as read
|
|
2139
|
-
const unread = msgs.filter((m) => m.from_name === coworker && !m.read);
|
|
2140
|
-
await Promise.allSettled(unread.map((m) => markRead(agentUrl, password, m.id)));
|
|
2141
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2142
|
-
res.send(renderMessages(msgs, humanName));
|
|
2143
|
-
}
|
|
2144
|
-
catch (err) {
|
|
2145
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2146
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2147
|
-
res.send(`<div class="empty-state" style="color:#ff6b6b">Error: ${escapeHtml(msg)}</div>`);
|
|
2148
|
-
}
|
|
2149
|
-
});
|
|
2150
|
-
// ── POST /send — HTMX form submit ────────────────────────────────────────
|
|
2151
|
-
app.post("/send", async (req, res) => {
|
|
2152
|
-
const { body: msgBody, coworker } = req.body;
|
|
2153
|
-
const body = msgBody?.trim();
|
|
2154
|
-
if (!body) {
|
|
2155
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2156
|
-
res.send(`<span class="send-err">Message cannot be empty.</span>`);
|
|
2157
|
-
return;
|
|
2158
|
-
}
|
|
2159
|
-
if (!coworker) {
|
|
2160
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2161
|
-
res.send(`<span class="send-err">No coworker selected.</span>`);
|
|
2162
|
-
return;
|
|
2163
|
-
}
|
|
2164
|
-
try {
|
|
2165
|
-
await apiFetch(agentUrl, password, "/messages", {
|
|
2166
|
-
method: "POST",
|
|
2167
|
-
body: JSON.stringify({ from: humanName, to: [coworker], body }),
|
|
2168
|
-
});
|
|
2169
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2170
|
-
res.send("");
|
|
2171
|
-
}
|
|
2172
|
-
catch (err) {
|
|
2173
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2174
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2175
|
-
res.send(`<span class="send-err">Failed: ${escapeHtml(msg)}</span>`);
|
|
2176
|
-
}
|
|
2177
|
-
});
|
|
2178
|
-
// ── GET /status — coworker status fragment (polled every 5s) ────────────
|
|
2179
|
-
app.get("/status", async (req, res) => {
|
|
2180
|
-
const coworker = req.query.coworker;
|
|
2181
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2182
|
-
if (!coworker) {
|
|
2183
|
-
res.send(`<span style="color:var(--text-dim)">—</span>`);
|
|
2184
|
-
return;
|
|
2185
|
-
}
|
|
2186
|
-
try {
|
|
2187
|
-
const status = await fetchCoworkerStatus(agentUrl, password, coworker);
|
|
2188
|
-
res.send(status ? escapeHtml(status) : `<span style="color:var(--text-dim)">—</span>`);
|
|
2189
|
-
}
|
|
2190
|
-
catch {
|
|
2191
|
-
res.send(`<span style="color:var(--text-dim)">—</span>`);
|
|
2192
|
-
}
|
|
2193
|
-
});
|
|
2194
|
-
// ── POST /reset — revert the coworker's session to first message ─────────
|
|
2195
|
-
app.post("/reset", async (req, res) => {
|
|
2196
|
-
const coworker = req.query.coworker;
|
|
2197
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2198
|
-
if (!coworker) {
|
|
2199
|
-
res.send(`<span style="color:var(--red)">✗ No coworker selected.</span>`);
|
|
2200
|
-
return;
|
|
2201
|
-
}
|
|
2202
|
-
try {
|
|
2203
|
-
await apiFetch(agentUrl, password, `/sessions/${encodeURIComponent(coworker)}/revert-to-start`, {
|
|
2204
|
-
method: "POST",
|
|
2205
|
-
});
|
|
2206
|
-
res.send(`<span style="color:var(--green)">✓ ${escapeHtml(coworker)} reset and restarted.</span>`);
|
|
2207
|
-
}
|
|
2208
|
-
catch (err) {
|
|
2209
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2210
|
-
res.send(`<span style="color:var(--red)">✗ Reset failed: ${escapeHtml(msg)}</span>`);
|
|
2211
|
-
}
|
|
2212
|
-
});
|
|
2213
|
-
// ── GET /ping — keeps the refresh dot alive ───────────────────────────────
|
|
2214
|
-
app.get("/ping", (_req, res) => {
|
|
2215
|
-
res.status(204).end();
|
|
2216
|
-
});
|
|
2217
|
-
const server = app.listen(port, host, () => {
|
|
2218
|
-
console.log(`Communicator running at http://${host}:${port}`);
|
|
2219
|
-
console.log(`Press Ctrl+C to stop.`);
|
|
2220
|
-
});
|
|
2221
|
-
server.on('error', (err) => {
|
|
2222
|
-
if (err.code === 'EADDRINUSE') {
|
|
2223
|
-
console.error(`Error: Port ${port} is already in use. Is another instance running?`);
|
|
2224
|
-
}
|
|
2225
|
-
else {
|
|
2226
|
-
console.error(`Error: ${err.message}`);
|
|
2227
|
-
}
|
|
2228
|
-
process.exit(1);
|
|
2229
|
-
});
|
|
2230
|
-
// Keep process alive
|
|
2231
|
-
await new Promise(() => { });
|
|
2232
|
-
}
|