promethios-overlay 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +67 -0
- package/src/launcher.js +75 -0
- package/src/main.js +192 -0
- package/src/preload.js +25 -0
- package/src/renderer/index.html +296 -0
- package/src/renderer/overlay.js +311 -0
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "promethios-overlay",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Always-on-top floating chat overlay for Promethios — works across all apps on Windows, Mac, and Linux. Pill mode, watching toggle, ambient context bar, and hotkey support.",
|
|
5
|
+
"main": "src/main.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "electron .",
|
|
8
|
+
"dev": "electron . --dev",
|
|
9
|
+
"build:win": "electron-builder --win",
|
|
10
|
+
"build:mac": "electron-builder --mac",
|
|
11
|
+
"build:linux": "electron-builder --linux"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"promethios",
|
|
15
|
+
"ai",
|
|
16
|
+
"overlay",
|
|
17
|
+
"electron",
|
|
18
|
+
"always-on-top",
|
|
19
|
+
"agent",
|
|
20
|
+
"ambient",
|
|
21
|
+
"floating"
|
|
22
|
+
],
|
|
23
|
+
"author": "Promethios <hello@promethios.ai>",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/wesheets/promethios.git",
|
|
28
|
+
"directory": "promethios-overlay"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://promethios.ai/local-bridge",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/wesheets/promethios/issues"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"src/",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"electron": "^29.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"electron-builder": "^24.0.0"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18.0.0"
|
|
49
|
+
},
|
|
50
|
+
"build": {
|
|
51
|
+
"appId": "ai.promethios.overlay",
|
|
52
|
+
"productName": "Promethios",
|
|
53
|
+
"files": ["src/**/*"],
|
|
54
|
+
"win": {
|
|
55
|
+
"target": "nsis",
|
|
56
|
+
"icon": "assets/icon.ico"
|
|
57
|
+
},
|
|
58
|
+
"mac": {
|
|
59
|
+
"target": "dmg",
|
|
60
|
+
"icon": "assets/icon.icns"
|
|
61
|
+
},
|
|
62
|
+
"linux": {
|
|
63
|
+
"target": "AppImage",
|
|
64
|
+
"icon": "assets/icon.png"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/launcher.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launcher.js
|
|
3
|
+
*
|
|
4
|
+
* Called by the bridge CLI after successful authentication.
|
|
5
|
+
* Spawns the Electron overlay window as a detached child process.
|
|
6
|
+
*
|
|
7
|
+
* Usage (from bridge CLI):
|
|
8
|
+
* const { launchOverlay } = require('promethios-overlay/src/launcher');
|
|
9
|
+
* launchOverlay({ authToken, apiBase, threadId, dev });
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { spawn } = require('child_process');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Launch the Promethios overlay Electron window.
|
|
18
|
+
* Returns the child process (or null if Electron is not available).
|
|
19
|
+
*/
|
|
20
|
+
function launchOverlay({ authToken, apiBase = 'https://api.promethios.ai', threadId = '', dev = false } = {}) {
|
|
21
|
+
// Find electron binary — try local node_modules first, then global
|
|
22
|
+
let electronBin = null;
|
|
23
|
+
|
|
24
|
+
const candidates = [
|
|
25
|
+
// Installed alongside this package
|
|
26
|
+
path.join(__dirname, '..', 'node_modules', '.bin', 'electron'),
|
|
27
|
+
path.join(__dirname, '..', 'node_modules', '.bin', 'electron.cmd'),
|
|
28
|
+
// Installed in the bridge CLI's node_modules
|
|
29
|
+
path.join(__dirname, '..', '..', 'promethios-bridge', 'node_modules', '.bin', 'electron'),
|
|
30
|
+
// Global electron
|
|
31
|
+
'electron',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
for (const candidate of candidates) {
|
|
35
|
+
try {
|
|
36
|
+
if (candidate === 'electron') {
|
|
37
|
+
// Try to resolve globally
|
|
38
|
+
require.resolve('electron');
|
|
39
|
+
electronBin = candidate;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
if (fs.existsSync(candidate)) {
|
|
43
|
+
electronBin = candidate;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
} catch { /* not found */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!electronBin) {
|
|
50
|
+
if (dev) console.log('[overlay] Electron not found — overlay will not launch. Install with: npm install -g electron');
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mainScript = path.join(__dirname, 'main.js');
|
|
55
|
+
|
|
56
|
+
const args = [mainScript];
|
|
57
|
+
if (authToken) { args.push('--token', authToken); }
|
|
58
|
+
if (apiBase) { args.push('--api', apiBase); }
|
|
59
|
+
if (threadId) { args.push('--thread', threadId); }
|
|
60
|
+
if (dev) { args.push('--dev'); }
|
|
61
|
+
|
|
62
|
+
const child = spawn(electronBin, args, {
|
|
63
|
+
detached: true,
|
|
64
|
+
stdio: 'ignore',
|
|
65
|
+
env: { ...process.env, ELECTRON_NO_ATTACH_CONSOLE: '1' },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
child.unref(); // Don't keep bridge CLI alive waiting for overlay
|
|
69
|
+
|
|
70
|
+
if (dev) console.log(`[overlay] Launched (pid ${child.pid})`);
|
|
71
|
+
|
|
72
|
+
return child;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { launchOverlay };
|
package/src/main.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promethios-overlay — Electron main process
|
|
3
|
+
*
|
|
4
|
+
* Creates an always-on-top floating chat window that works across all apps
|
|
5
|
+
* on Windows, Mac, and Linux. Launched by the bridge CLI after authentication.
|
|
6
|
+
*
|
|
7
|
+
* Launch args (passed from bridge CLI via child_process.spawn):
|
|
8
|
+
* --token <authToken> Bearer token for Promethios API
|
|
9
|
+
* --api <apiBase> API base URL (default: https://api.promethios.ai)
|
|
10
|
+
* --thread <threadId> Optional: open a specific thread on launch
|
|
11
|
+
* --dev Enable verbose logging
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { app, BrowserWindow, globalShortcut, ipcMain, screen, Tray, Menu, nativeImage } = require('electron');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
// ── Parse launch args ────────────────────────────────────────────────────────
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const getArg = (name) => {
|
|
20
|
+
const idx = args.indexOf(`--${name}`);
|
|
21
|
+
return idx !== -1 ? args[idx + 1] : null;
|
|
22
|
+
};
|
|
23
|
+
const AUTH_TOKEN = getArg('token') || '';
|
|
24
|
+
const API_BASE = getArg('api') || 'https://api.promethios.ai';
|
|
25
|
+
const THREAD_ID = getArg('thread') || '';
|
|
26
|
+
const DEV = args.includes('--dev');
|
|
27
|
+
|
|
28
|
+
// ── Window state ─────────────────────────────────────────────────────────────
|
|
29
|
+
let overlayWindow = null;
|
|
30
|
+
let tray = null;
|
|
31
|
+
let isExpanded = false;
|
|
32
|
+
|
|
33
|
+
// Pill dimensions (collapsed)
|
|
34
|
+
const PILL_W = 220;
|
|
35
|
+
const PILL_H = 52;
|
|
36
|
+
|
|
37
|
+
// Chat dimensions (expanded)
|
|
38
|
+
const CHAT_W = 380;
|
|
39
|
+
const CHAT_H = 560;
|
|
40
|
+
|
|
41
|
+
// ── App ready ────────────────────────────────────────────────────────────────
|
|
42
|
+
app.whenReady().then(() => {
|
|
43
|
+
createOverlayWindow();
|
|
44
|
+
registerHotkey();
|
|
45
|
+
createTray();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
app.on('window-all-closed', () => {
|
|
49
|
+
// Keep app alive even if window is closed — user can restore via tray
|
|
50
|
+
if (process.platform !== 'darwin') {
|
|
51
|
+
// On Windows/Linux, keep running in tray
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
app.on('will-quit', () => {
|
|
56
|
+
globalShortcut.unregisterAll();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── Create the overlay window ────────────────────────────────────────────────
|
|
60
|
+
function createOverlayWindow() {
|
|
61
|
+
const { width: screenW, height: screenH } = screen.getPrimaryDisplay().workAreaSize;
|
|
62
|
+
|
|
63
|
+
overlayWindow = new BrowserWindow({
|
|
64
|
+
width: PILL_W,
|
|
65
|
+
height: PILL_H,
|
|
66
|
+
x: screenW - PILL_W - 24,
|
|
67
|
+
y: screenH - PILL_H - 24,
|
|
68
|
+
frame: false,
|
|
69
|
+
transparent: true,
|
|
70
|
+
alwaysOnTop: true,
|
|
71
|
+
skipTaskbar: true,
|
|
72
|
+
resizable: false,
|
|
73
|
+
movable: true,
|
|
74
|
+
hasShadow: true,
|
|
75
|
+
webPreferences: {
|
|
76
|
+
nodeIntegration: false,
|
|
77
|
+
contextIsolation: true,
|
|
78
|
+
preload: path.join(__dirname, 'preload.js'),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Always on top — level 'floating' keeps it above most apps including full-screen
|
|
83
|
+
overlayWindow.setAlwaysOnTop(true, 'floating');
|
|
84
|
+
overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
85
|
+
|
|
86
|
+
overlayWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
|
87
|
+
|
|
88
|
+
// Pass config to renderer once DOM is ready
|
|
89
|
+
overlayWindow.webContents.on('did-finish-load', () => {
|
|
90
|
+
overlayWindow.webContents.send('config', {
|
|
91
|
+
authToken: AUTH_TOKEN,
|
|
92
|
+
apiBase: API_BASE,
|
|
93
|
+
threadId: THREAD_ID,
|
|
94
|
+
dev: DEV,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (DEV) {
|
|
99
|
+
overlayWindow.webContents.openDevTools({ mode: 'detach' });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Toggle between pill and expanded chat ────────────────────────────────────
|
|
104
|
+
function toggleExpanded(expand) {
|
|
105
|
+
if (expand === undefined) expand = !isExpanded;
|
|
106
|
+
isExpanded = expand;
|
|
107
|
+
|
|
108
|
+
const { width: screenW, height: screenH } = screen.getPrimaryDisplay().workAreaSize;
|
|
109
|
+
|
|
110
|
+
if (isExpanded) {
|
|
111
|
+
const x = screenW - CHAT_W - 24;
|
|
112
|
+
const y = screenH - CHAT_H - 24;
|
|
113
|
+
overlayWindow.setBounds({ x, y, width: CHAT_W, height: CHAT_H }, true);
|
|
114
|
+
overlayWindow.webContents.send('expand', true);
|
|
115
|
+
} else {
|
|
116
|
+
const x = screenW - PILL_W - 24;
|
|
117
|
+
const y = screenH - PILL_H - 24;
|
|
118
|
+
overlayWindow.setBounds({ x, y, width: PILL_W, height: PILL_H }, true);
|
|
119
|
+
overlayWindow.webContents.send('expand', false);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── IPC from renderer ────────────────────────────────────────────────────────
|
|
124
|
+
ipcMain.on('toggle-expand', (_, expand) => toggleExpanded(expand));
|
|
125
|
+
|
|
126
|
+
ipcMain.on('set-watching', (_, watching) => {
|
|
127
|
+
// Relay to renderer (already handled there) — also log for debug
|
|
128
|
+
if (DEV) console.log('[overlay] watching:', watching);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
ipcMain.on('close-overlay', () => {
|
|
132
|
+
overlayWindow.hide();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
ipcMain.on('show-overlay', () => {
|
|
136
|
+
overlayWindow.show();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── Global hotkey: Ctrl+Shift+P (Win/Linux) or Cmd+Shift+P (Mac) ─────────────
|
|
140
|
+
function registerHotkey() {
|
|
141
|
+
const hotkey = process.platform === 'darwin' ? 'Command+Shift+P' : 'Control+Shift+P';
|
|
142
|
+
const registered = globalShortcut.register(hotkey, () => {
|
|
143
|
+
if (overlayWindow.isVisible()) {
|
|
144
|
+
toggleExpanded();
|
|
145
|
+
} else {
|
|
146
|
+
overlayWindow.show();
|
|
147
|
+
toggleExpanded(true);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
if (DEV) console.log(`[overlay] Hotkey ${hotkey} registered:`, registered);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── System tray icon ─────────────────────────────────────────────────────────
|
|
154
|
+
function createTray() {
|
|
155
|
+
// Use a simple 16x16 template image (will be replaced with real icon in production)
|
|
156
|
+
const iconSize = process.platform === 'darwin' ? 16 : 32;
|
|
157
|
+
const trayIcon = nativeImage.createEmpty();
|
|
158
|
+
tray = new Tray(trayIcon);
|
|
159
|
+
|
|
160
|
+
const contextMenu = Menu.buildFromTemplate([
|
|
161
|
+
{
|
|
162
|
+
label: 'Show Promethios',
|
|
163
|
+
click: () => {
|
|
164
|
+
overlayWindow.show();
|
|
165
|
+
toggleExpanded(true);
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
label: 'Hide',
|
|
170
|
+
click: () => overlayWindow.hide(),
|
|
171
|
+
},
|
|
172
|
+
{ type: 'separator' },
|
|
173
|
+
{
|
|
174
|
+
label: 'Quit',
|
|
175
|
+
click: () => {
|
|
176
|
+
app.quit();
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
tray.setToolTip('Promethios AI Overlay');
|
|
182
|
+
tray.setContextMenu(contextMenu);
|
|
183
|
+
|
|
184
|
+
tray.on('click', () => {
|
|
185
|
+
if (overlayWindow.isVisible()) {
|
|
186
|
+
toggleExpanded();
|
|
187
|
+
} else {
|
|
188
|
+
overlayWindow.show();
|
|
189
|
+
toggleExpanded(true);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
package/src/preload.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preload.js — Electron contextBridge
|
|
3
|
+
*
|
|
4
|
+
* Exposes a safe, limited API to the renderer process.
|
|
5
|
+
* No direct Node.js access in the renderer — all IPC goes through here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { contextBridge, ipcRenderer } = require('electron');
|
|
9
|
+
|
|
10
|
+
contextBridge.exposeInMainWorld('promethios', {
|
|
11
|
+
// Receive config from main process (authToken, apiBase, threadId)
|
|
12
|
+
onConfig: (cb) => ipcRenderer.on('config', (_, data) => cb(data)),
|
|
13
|
+
|
|
14
|
+
// Receive expand/collapse state changes from main process
|
|
15
|
+
onExpand: (cb) => ipcRenderer.on('expand', (_, expanded) => cb(expanded)),
|
|
16
|
+
|
|
17
|
+
// Tell main process to toggle expand/collapse
|
|
18
|
+
toggleExpand: (expand) => ipcRenderer.send('toggle-expand', expand),
|
|
19
|
+
|
|
20
|
+
// Tell main process about watching state change
|
|
21
|
+
setWatching: (watching) => ipcRenderer.send('set-watching', watching),
|
|
22
|
+
|
|
23
|
+
// Close the overlay window
|
|
24
|
+
close: () => ipcRenderer.send('close-overlay'),
|
|
25
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Promethios</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* ── Reset ─────────────────────────────────────────────────────────────── */
|
|
9
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
10
|
+
html, body { width: 100%; height: 100%; overflow: hidden; background: transparent; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
|
11
|
+
|
|
12
|
+
/* ── Pill (collapsed state) ─────────────────────────────────────────────── */
|
|
13
|
+
#pill {
|
|
14
|
+
position: fixed; bottom: 0; right: 0;
|
|
15
|
+
width: 220px; height: 52px;
|
|
16
|
+
background: rgba(15, 15, 20, 0.92);
|
|
17
|
+
backdrop-filter: blur(16px);
|
|
18
|
+
border: 1px solid rgba(139, 92, 246, 0.4);
|
|
19
|
+
border-radius: 26px;
|
|
20
|
+
display: flex; align-items: center; gap: 10px;
|
|
21
|
+
padding: 0 14px;
|
|
22
|
+
cursor: pointer;
|
|
23
|
+
-webkit-app-region: drag;
|
|
24
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 0 1px rgba(139,92,246,0.15);
|
|
25
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
26
|
+
user-select: none;
|
|
27
|
+
}
|
|
28
|
+
#pill:hover {
|
|
29
|
+
border-color: rgba(139, 92, 246, 0.7);
|
|
30
|
+
box-shadow: 0 4px 32px rgba(139,92,246,0.25), 0 0 0 1px rgba(139,92,246,0.3);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Logo mark */
|
|
34
|
+
.pill-logo {
|
|
35
|
+
width: 28px; height: 28px; border-radius: 8px;
|
|
36
|
+
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
|
37
|
+
display: flex; align-items: center; justify-content: center;
|
|
38
|
+
font-size: 14px; flex-shrink: 0;
|
|
39
|
+
-webkit-app-region: no-drag;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.pill-label {
|
|
43
|
+
flex: 1;
|
|
44
|
+
font-size: 13px; font-weight: 600;
|
|
45
|
+
color: #e2e8f0;
|
|
46
|
+
letter-spacing: 0.01em;
|
|
47
|
+
-webkit-app-region: no-drag;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Status dot — green=watching, yellow=paused */
|
|
51
|
+
#status-dot {
|
|
52
|
+
width: 8px; height: 8px; border-radius: 50%;
|
|
53
|
+
background: #22c55e;
|
|
54
|
+
flex-shrink: 0;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
-webkit-app-region: no-drag;
|
|
57
|
+
transition: background 0.3s;
|
|
58
|
+
position: relative;
|
|
59
|
+
}
|
|
60
|
+
#status-dot.paused { background: #eab308; }
|
|
61
|
+
#status-dot::after {
|
|
62
|
+
content: attr(data-tooltip);
|
|
63
|
+
position: absolute; bottom: 14px; right: 0;
|
|
64
|
+
background: rgba(15,15,20,0.95); color: #e2e8f0;
|
|
65
|
+
font-size: 11px; padding: 3px 8px; border-radius: 4px;
|
|
66
|
+
white-space: nowrap; pointer-events: none;
|
|
67
|
+
opacity: 0; transition: opacity 0.15s;
|
|
68
|
+
}
|
|
69
|
+
#status-dot:hover::after { opacity: 1; }
|
|
70
|
+
|
|
71
|
+
/* ── Chat window (expanded state) ───────────────────────────────────────── */
|
|
72
|
+
#chat {
|
|
73
|
+
display: none;
|
|
74
|
+
position: fixed; bottom: 0; right: 0;
|
|
75
|
+
width: 380px; height: 560px;
|
|
76
|
+
background: rgba(10, 10, 16, 0.96);
|
|
77
|
+
backdrop-filter: blur(20px);
|
|
78
|
+
border: 1px solid rgba(139, 92, 246, 0.35);
|
|
79
|
+
border-radius: 16px;
|
|
80
|
+
flex-direction: column;
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
box-shadow: 0 8px 48px rgba(0,0,0,0.7), 0 0 0 1px rgba(139,92,246,0.1);
|
|
83
|
+
}
|
|
84
|
+
#chat.visible { display: flex; }
|
|
85
|
+
|
|
86
|
+
/* Header */
|
|
87
|
+
.chat-header {
|
|
88
|
+
display: flex; align-items: center; gap: 10px;
|
|
89
|
+
padding: 12px 14px;
|
|
90
|
+
background: rgba(139, 92, 246, 0.08);
|
|
91
|
+
border-bottom: 1px solid rgba(139, 92, 246, 0.2);
|
|
92
|
+
-webkit-app-region: drag;
|
|
93
|
+
flex-shrink: 0;
|
|
94
|
+
}
|
|
95
|
+
.chat-header .logo {
|
|
96
|
+
width: 26px; height: 26px; border-radius: 7px;
|
|
97
|
+
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
|
98
|
+
display: flex; align-items: center; justify-content: center;
|
|
99
|
+
font-size: 13px; flex-shrink: 0;
|
|
100
|
+
-webkit-app-region: no-drag;
|
|
101
|
+
}
|
|
102
|
+
.chat-header .title {
|
|
103
|
+
flex: 1; font-size: 13px; font-weight: 600; color: #e2e8f0;
|
|
104
|
+
-webkit-app-region: no-drag;
|
|
105
|
+
}
|
|
106
|
+
.header-actions {
|
|
107
|
+
display: flex; align-items: center; gap: 8px;
|
|
108
|
+
-webkit-app-region: no-drag;
|
|
109
|
+
}
|
|
110
|
+
.header-btn {
|
|
111
|
+
width: 26px; height: 26px; border-radius: 6px;
|
|
112
|
+
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
|
|
113
|
+
color: #94a3b8; font-size: 12px;
|
|
114
|
+
display: flex; align-items: center; justify-content: center;
|
|
115
|
+
cursor: pointer; transition: background 0.15s, color 0.15s;
|
|
116
|
+
}
|
|
117
|
+
.header-btn:hover { background: rgba(255,255,255,0.12); color: #e2e8f0; }
|
|
118
|
+
|
|
119
|
+
/* Watching toggle in header */
|
|
120
|
+
#watching-toggle {
|
|
121
|
+
display: flex; align-items: center; gap: 5px;
|
|
122
|
+
font-size: 11px; color: #94a3b8; cursor: pointer;
|
|
123
|
+
padding: 3px 8px; border-radius: 12px;
|
|
124
|
+
background: rgba(255,255,255,0.05);
|
|
125
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
126
|
+
transition: background 0.15s;
|
|
127
|
+
-webkit-app-region: no-drag;
|
|
128
|
+
}
|
|
129
|
+
#watching-toggle:hover { background: rgba(255,255,255,0.1); }
|
|
130
|
+
#watching-toggle .dot {
|
|
131
|
+
width: 6px; height: 6px; border-radius: 50%;
|
|
132
|
+
background: #22c55e; transition: background 0.3s;
|
|
133
|
+
}
|
|
134
|
+
#watching-toggle.paused .dot { background: #eab308; }
|
|
135
|
+
#watching-toggle.paused .watch-label::after { content: 'Paused'; }
|
|
136
|
+
#watching-toggle:not(.paused) .watch-label::after { content: 'Watching'; }
|
|
137
|
+
|
|
138
|
+
/* Ambient context bar */
|
|
139
|
+
#context-bar {
|
|
140
|
+
padding: 7px 14px;
|
|
141
|
+
background: rgba(139, 92, 246, 0.05);
|
|
142
|
+
border-bottom: 1px solid rgba(139, 92, 246, 0.12);
|
|
143
|
+
font-size: 11px; color: #7c6fa0;
|
|
144
|
+
display: flex; align-items: center; gap: 6px;
|
|
145
|
+
flex-shrink: 0; min-height: 30px;
|
|
146
|
+
}
|
|
147
|
+
#context-bar .ctx-icon { font-size: 12px; }
|
|
148
|
+
#context-bar .ctx-text {
|
|
149
|
+
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
150
|
+
}
|
|
151
|
+
#context-bar.hidden { display: none; }
|
|
152
|
+
|
|
153
|
+
/* Messages area */
|
|
154
|
+
#messages {
|
|
155
|
+
flex: 1; overflow-y: auto; padding: 12px 14px;
|
|
156
|
+
display: flex; flex-direction: column; gap: 10px;
|
|
157
|
+
scroll-behavior: smooth;
|
|
158
|
+
}
|
|
159
|
+
#messages::-webkit-scrollbar { width: 4px; }
|
|
160
|
+
#messages::-webkit-scrollbar-track { background: transparent; }
|
|
161
|
+
#messages::-webkit-scrollbar-thumb { background: rgba(139,92,246,0.3); border-radius: 2px; }
|
|
162
|
+
|
|
163
|
+
.msg { display: flex; flex-direction: column; gap: 2px; max-width: 88%; }
|
|
164
|
+
.msg.user { align-self: flex-end; align-items: flex-end; }
|
|
165
|
+
.msg.agent { align-self: flex-start; align-items: flex-start; }
|
|
166
|
+
|
|
167
|
+
.msg-bubble {
|
|
168
|
+
padding: 8px 12px; border-radius: 12px;
|
|
169
|
+
font-size: 13px; line-height: 1.5; color: #e2e8f0;
|
|
170
|
+
word-break: break-word;
|
|
171
|
+
}
|
|
172
|
+
.msg.user .msg-bubble {
|
|
173
|
+
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
|
174
|
+
border-bottom-right-radius: 4px;
|
|
175
|
+
}
|
|
176
|
+
.msg.agent .msg-bubble {
|
|
177
|
+
background: rgba(255,255,255,0.07);
|
|
178
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
179
|
+
border-bottom-left-radius: 4px;
|
|
180
|
+
}
|
|
181
|
+
.msg-time { font-size: 10px; color: #4b5563; }
|
|
182
|
+
|
|
183
|
+
/* Typing indicator */
|
|
184
|
+
#typing {
|
|
185
|
+
display: none; align-self: flex-start;
|
|
186
|
+
padding: 8px 14px; background: rgba(255,255,255,0.07);
|
|
187
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
188
|
+
border-radius: 12px; border-bottom-left-radius: 4px;
|
|
189
|
+
}
|
|
190
|
+
#typing.visible { display: flex; gap: 4px; align-items: center; }
|
|
191
|
+
.dot-pulse { width: 6px; height: 6px; border-radius: 50%; background: #7c3aed; animation: pulse 1.2s infinite; }
|
|
192
|
+
.dot-pulse:nth-child(2) { animation-delay: 0.2s; }
|
|
193
|
+
.dot-pulse:nth-child(3) { animation-delay: 0.4s; }
|
|
194
|
+
@keyframes pulse { 0%,80%,100% { opacity: 0.3; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1); } }
|
|
195
|
+
|
|
196
|
+
/* Input area */
|
|
197
|
+
.chat-input-area {
|
|
198
|
+
padding: 10px 12px;
|
|
199
|
+
border-top: 1px solid rgba(255,255,255,0.07);
|
|
200
|
+
display: flex; align-items: flex-end; gap: 8px;
|
|
201
|
+
flex-shrink: 0;
|
|
202
|
+
-webkit-app-region: no-drag;
|
|
203
|
+
}
|
|
204
|
+
#msg-input {
|
|
205
|
+
flex: 1; background: rgba(255,255,255,0.06);
|
|
206
|
+
border: 1px solid rgba(255,255,255,0.1); border-radius: 10px;
|
|
207
|
+
color: #e2e8f0; font-size: 13px; padding: 8px 12px;
|
|
208
|
+
resize: none; outline: none; max-height: 100px;
|
|
209
|
+
font-family: inherit; line-height: 1.4;
|
|
210
|
+
transition: border-color 0.15s;
|
|
211
|
+
}
|
|
212
|
+
#msg-input::placeholder { color: #4b5563; }
|
|
213
|
+
#msg-input:focus { border-color: rgba(139,92,246,0.5); }
|
|
214
|
+
|
|
215
|
+
#send-btn {
|
|
216
|
+
width: 34px; height: 34px; border-radius: 9px;
|
|
217
|
+
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
|
218
|
+
border: none; cursor: pointer; color: white; font-size: 14px;
|
|
219
|
+
display: flex; align-items: center; justify-content: center;
|
|
220
|
+
transition: opacity 0.15s, transform 0.1s; flex-shrink: 0;
|
|
221
|
+
}
|
|
222
|
+
#send-btn:hover { opacity: 0.85; }
|
|
223
|
+
#send-btn:active { transform: scale(0.95); }
|
|
224
|
+
#send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
225
|
+
|
|
226
|
+
/* Empty state */
|
|
227
|
+
#empty-state {
|
|
228
|
+
flex: 1; display: flex; flex-direction: column;
|
|
229
|
+
align-items: center; justify-content: center; gap: 8px;
|
|
230
|
+
color: #4b5563; text-align: center; padding: 20px;
|
|
231
|
+
}
|
|
232
|
+
#empty-state .empty-icon { font-size: 32px; }
|
|
233
|
+
#empty-state .empty-title { font-size: 14px; font-weight: 600; color: #6b7280; }
|
|
234
|
+
#empty-state .empty-sub { font-size: 12px; line-height: 1.5; }
|
|
235
|
+
</style>
|
|
236
|
+
</head>
|
|
237
|
+
<body>
|
|
238
|
+
|
|
239
|
+
<!-- ── Pill (collapsed) ──────────────────────────────────────────────────── -->
|
|
240
|
+
<div id="pill">
|
|
241
|
+
<div class="pill-logo">⬡</div>
|
|
242
|
+
<span class="pill-label">Promethios</span>
|
|
243
|
+
<div id="status-dot" data-tooltip="Watching — click to pause"></div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<!-- ── Chat window (expanded) ────────────────────────────────────────────── -->
|
|
247
|
+
<div id="chat">
|
|
248
|
+
|
|
249
|
+
<!-- Header -->
|
|
250
|
+
<div class="chat-header">
|
|
251
|
+
<div class="logo">⬡</div>
|
|
252
|
+
<span class="title">Promethios</span>
|
|
253
|
+
<div class="header-actions">
|
|
254
|
+
<!-- Watching toggle -->
|
|
255
|
+
<div id="watching-toggle" title="Toggle context watching">
|
|
256
|
+
<div class="dot"></div>
|
|
257
|
+
<span class="watch-label"></span>
|
|
258
|
+
</div>
|
|
259
|
+
<!-- Collapse to pill -->
|
|
260
|
+
<div class="header-btn" id="collapse-btn" title="Collapse">╌</div>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<!-- Ambient context bar -->
|
|
265
|
+
<div id="context-bar" class="hidden">
|
|
266
|
+
<span class="ctx-icon">⚡</span>
|
|
267
|
+
<span class="ctx-text" id="ctx-text">Watching your screen...</span>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<!-- Messages -->
|
|
271
|
+
<div id="messages">
|
|
272
|
+
<div id="empty-state">
|
|
273
|
+
<div class="empty-icon">🤖</div>
|
|
274
|
+
<div class="empty-title">Promethios is ready</div>
|
|
275
|
+
<div class="empty-sub">Ask anything — I can see what you're working on and help without you switching tabs.</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<!-- Typing indicator -->
|
|
280
|
+
<div id="typing">
|
|
281
|
+
<div class="dot-pulse"></div>
|
|
282
|
+
<div class="dot-pulse"></div>
|
|
283
|
+
<div class="dot-pulse"></div>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<!-- Input -->
|
|
287
|
+
<div class="chat-input-area">
|
|
288
|
+
<textarea id="msg-input" rows="1" placeholder="Ask anything..."></textarea>
|
|
289
|
+
<button id="send-btn">↑</button>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<script src="overlay.js"></script>
|
|
295
|
+
</body>
|
|
296
|
+
</html>
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* overlay.js — Renderer process logic
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Config injection from main process (authToken, apiBase, threadId)
|
|
6
|
+
* - Pill ↔ chat expand/collapse
|
|
7
|
+
* - Watching toggle (green/yellow dot)
|
|
8
|
+
* - Ambient context display in the context bar
|
|
9
|
+
* - Chat message sending via Promethios API
|
|
10
|
+
* - Streaming response rendering
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
14
|
+
let config = { authToken: '', apiBase: 'https://api.promethios.ai', threadId: '', dev: false };
|
|
15
|
+
let isExpanded = false;
|
|
16
|
+
let isWatching = true;
|
|
17
|
+
let messages = [];
|
|
18
|
+
let isStreaming = false;
|
|
19
|
+
let contextPollTimer = null;
|
|
20
|
+
|
|
21
|
+
// ── DOM refs ─────────────────────────────────────────────────────────────────
|
|
22
|
+
const pill = document.getElementById('pill');
|
|
23
|
+
const chat = document.getElementById('chat');
|
|
24
|
+
const statusDot = document.getElementById('status-dot');
|
|
25
|
+
const watchingToggle = document.getElementById('watching-toggle');
|
|
26
|
+
const collapseBtn = document.getElementById('collapse-btn');
|
|
27
|
+
const contextBar = document.getElementById('context-bar');
|
|
28
|
+
const ctxText = document.getElementById('ctx-text');
|
|
29
|
+
const messagesEl = document.getElementById('messages');
|
|
30
|
+
const emptyState = document.getElementById('empty-state');
|
|
31
|
+
const typingEl = document.getElementById('typing');
|
|
32
|
+
const msgInput = document.getElementById('msg-input');
|
|
33
|
+
const sendBtn = document.getElementById('send-btn');
|
|
34
|
+
|
|
35
|
+
// ── Receive config from main process ─────────────────────────────────────────
|
|
36
|
+
window.promethios.onConfig((data) => {
|
|
37
|
+
config = { ...config, ...data };
|
|
38
|
+
if (config.dev) console.log('[overlay] Config received:', config);
|
|
39
|
+
startContextPoll();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── Receive expand/collapse from main process ─────────────────────────────────
|
|
43
|
+
window.promethios.onExpand((expanded) => {
|
|
44
|
+
setExpanded(expanded);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── Pill click → expand ───────────────────────────────────────────────────────
|
|
48
|
+
pill.addEventListener('click', (e) => {
|
|
49
|
+
if (e.target === statusDot) return; // handled separately
|
|
50
|
+
window.promethios.toggleExpand(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── Status dot click → toggle watching ───────────────────────────────────────
|
|
54
|
+
statusDot.addEventListener('click', (e) => {
|
|
55
|
+
e.stopPropagation();
|
|
56
|
+
toggleWatching();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
watchingToggle.addEventListener('click', () => {
|
|
60
|
+
toggleWatching();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── Collapse button ───────────────────────────────────────────────────────────
|
|
64
|
+
collapseBtn.addEventListener('click', () => {
|
|
65
|
+
window.promethios.toggleExpand(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── Send message ──────────────────────────────────────────────────────────────
|
|
69
|
+
sendBtn.addEventListener('click', sendMessage);
|
|
70
|
+
msgInput.addEventListener('keydown', (e) => {
|
|
71
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
sendMessage();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Auto-resize textarea
|
|
78
|
+
msgInput.addEventListener('input', () => {
|
|
79
|
+
msgInput.style.height = 'auto';
|
|
80
|
+
msgInput.style.height = Math.min(msgInput.scrollHeight, 100) + 'px';
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── Expand / collapse ─────────────────────────────────────────────────────────
|
|
84
|
+
function setExpanded(expanded) {
|
|
85
|
+
isExpanded = expanded;
|
|
86
|
+
if (expanded) {
|
|
87
|
+
pill.style.display = 'none';
|
|
88
|
+
chat.classList.add('visible');
|
|
89
|
+
msgInput.focus();
|
|
90
|
+
} else {
|
|
91
|
+
pill.style.display = 'flex';
|
|
92
|
+
chat.classList.remove('visible');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Watching toggle ───────────────────────────────────────────────────────────
|
|
97
|
+
function toggleWatching() {
|
|
98
|
+
isWatching = !isWatching;
|
|
99
|
+
|
|
100
|
+
// Update pill dot
|
|
101
|
+
statusDot.classList.toggle('paused', !isWatching);
|
|
102
|
+
statusDot.setAttribute('data-tooltip', isWatching
|
|
103
|
+
? 'Watching — click to pause'
|
|
104
|
+
: 'Paused — click to resume watching');
|
|
105
|
+
|
|
106
|
+
// Update header toggle
|
|
107
|
+
watchingToggle.classList.toggle('paused', !isWatching);
|
|
108
|
+
|
|
109
|
+
// Hide/show context bar
|
|
110
|
+
if (!isWatching) {
|
|
111
|
+
contextBar.classList.add('hidden');
|
|
112
|
+
ctxText.textContent = '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Notify main process
|
|
116
|
+
window.promethios.setWatching(isWatching);
|
|
117
|
+
|
|
118
|
+
// Tell the API to pause/resume context capture
|
|
119
|
+
if (config.authToken) {
|
|
120
|
+
fetch(`${config.apiBase}/api/local-bridge/context/watching`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { Authorization: `Bearer ${config.authToken}`, 'Content-Type': 'application/json' },
|
|
123
|
+
body: JSON.stringify({ watching: isWatching }),
|
|
124
|
+
}).catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Context polling ───────────────────────────────────────────────────────────
|
|
129
|
+
function startContextPoll() {
|
|
130
|
+
if (contextPollTimer) clearInterval(contextPollTimer);
|
|
131
|
+
contextPollTimer = setInterval(fetchContext, 5000);
|
|
132
|
+
fetchContext(); // immediate first fetch
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function fetchContext() {
|
|
136
|
+
if (!isWatching || !config.authToken) return;
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(`${config.apiBase}/api/local-bridge/context`, {
|
|
139
|
+
headers: { Authorization: `Bearer ${config.authToken}` },
|
|
140
|
+
});
|
|
141
|
+
if (!res.ok) return;
|
|
142
|
+
const ctx = await res.json();
|
|
143
|
+
updateContextBar(ctx);
|
|
144
|
+
} catch { /* ignore */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function updateContextBar(ctx) {
|
|
148
|
+
if (!ctx || (!ctx.active_app && !ctx.active_url)) {
|
|
149
|
+
contextBar.classList.add('hidden');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
contextBar.classList.remove('hidden');
|
|
153
|
+
|
|
154
|
+
let label = '';
|
|
155
|
+
if (ctx.active_url) {
|
|
156
|
+
try {
|
|
157
|
+
const url = new URL(ctx.active_url);
|
|
158
|
+
label = url.hostname.replace('www.', '');
|
|
159
|
+
if (ctx.active_window?.title) {
|
|
160
|
+
const title = ctx.active_window.title.replace(/ [-–|].*$/, '').trim();
|
|
161
|
+
if (title && title.length < 50) label = `${title} · ${label}`;
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
label = ctx.active_url.slice(0, 60);
|
|
165
|
+
}
|
|
166
|
+
} else if (ctx.active_app) {
|
|
167
|
+
label = ctx.active_app;
|
|
168
|
+
if (ctx.active_window?.title) {
|
|
169
|
+
const title = ctx.active_window.title.replace(/ [-–|].*$/, '').trim();
|
|
170
|
+
if (title && title.length < 50 && title !== ctx.active_app) label = `${title} · ${ctx.active_app}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
ctxText.textContent = label;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Send message ──────────────────────────────────────────────────────────────
|
|
178
|
+
async function sendMessage() {
|
|
179
|
+
const text = msgInput.value.trim();
|
|
180
|
+
if (!text || isStreaming) return;
|
|
181
|
+
if (!config.authToken) {
|
|
182
|
+
appendMessage('agent', 'Not connected. Please restart the bridge with a valid token.');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Clear input
|
|
187
|
+
msgInput.value = '';
|
|
188
|
+
msgInput.style.height = 'auto';
|
|
189
|
+
|
|
190
|
+
// Add user message to UI
|
|
191
|
+
appendMessage('user', text);
|
|
192
|
+
hideEmptyState();
|
|
193
|
+
|
|
194
|
+
// Show typing indicator
|
|
195
|
+
setStreaming(true);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
// Use the Promethios chat/stream API
|
|
199
|
+
// If we have a threadId, send to that thread; otherwise create a new one
|
|
200
|
+
const endpoint = config.threadId
|
|
201
|
+
? `${config.apiBase}/api/chat/stream`
|
|
202
|
+
: `${config.apiBase}/api/chat/stream`;
|
|
203
|
+
|
|
204
|
+
const body = {
|
|
205
|
+
message: text,
|
|
206
|
+
threadId: config.threadId || null,
|
|
207
|
+
source: 'overlay',
|
|
208
|
+
overlayMode: true,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const res = await fetch(endpoint, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: {
|
|
214
|
+
Authorization: `Bearer ${config.authToken}`,
|
|
215
|
+
'Content-Type': 'application/json',
|
|
216
|
+
Accept: 'text/event-stream',
|
|
217
|
+
},
|
|
218
|
+
body: JSON.stringify(body),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (!res.ok) {
|
|
222
|
+
const err = await res.text();
|
|
223
|
+
appendMessage('agent', `Error: ${res.status} — ${err.slice(0, 200)}`);
|
|
224
|
+
setStreaming(false);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Stream the response
|
|
229
|
+
const reader = res.body.getReader();
|
|
230
|
+
const decoder = new TextDecoder();
|
|
231
|
+
let agentMsgEl = null;
|
|
232
|
+
let buffer = '';
|
|
233
|
+
|
|
234
|
+
while (true) {
|
|
235
|
+
const { done, value } = await reader.read();
|
|
236
|
+
if (done) break;
|
|
237
|
+
|
|
238
|
+
buffer += decoder.decode(value, { stream: true });
|
|
239
|
+
const lines = buffer.split('\n');
|
|
240
|
+
buffer = lines.pop(); // keep incomplete line
|
|
241
|
+
|
|
242
|
+
for (const line of lines) {
|
|
243
|
+
if (!line.startsWith('data: ')) continue;
|
|
244
|
+
const data = line.slice(6).trim();
|
|
245
|
+
if (data === '[DONE]') continue;
|
|
246
|
+
try {
|
|
247
|
+
const parsed = JSON.parse(data);
|
|
248
|
+
const delta = parsed.choices?.[0]?.delta?.content || parsed.delta?.text || parsed.text || '';
|
|
249
|
+
if (delta) {
|
|
250
|
+
if (!agentMsgEl) {
|
|
251
|
+
agentMsgEl = appendMessage('agent', '', true);
|
|
252
|
+
}
|
|
253
|
+
agentMsgEl.querySelector('.msg-bubble').textContent += delta;
|
|
254
|
+
scrollToBottom();
|
|
255
|
+
}
|
|
256
|
+
// Capture threadId if returned
|
|
257
|
+
if (parsed.threadId && !config.threadId) {
|
|
258
|
+
config.threadId = parsed.threadId;
|
|
259
|
+
}
|
|
260
|
+
} catch { /* non-JSON line, skip */ }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!agentMsgEl) {
|
|
265
|
+
appendMessage('agent', '(No response)');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
} catch (err) {
|
|
269
|
+
appendMessage('agent', `Connection error: ${err.message}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
setStreaming(false);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── UI helpers ────────────────────────────────────────────────────────────────
|
|
276
|
+
function appendMessage(role, text, returnEl = false) {
|
|
277
|
+
const now = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
278
|
+
const div = document.createElement('div');
|
|
279
|
+
div.className = `msg ${role}`;
|
|
280
|
+
div.innerHTML = `
|
|
281
|
+
<div class="msg-bubble">${escapeHtml(text)}</div>
|
|
282
|
+
<span class="msg-time">${now}</span>
|
|
283
|
+
`;
|
|
284
|
+
messagesEl.appendChild(div);
|
|
285
|
+
scrollToBottom();
|
|
286
|
+
if (returnEl) return div;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function hideEmptyState() {
|
|
290
|
+
if (emptyState) emptyState.style.display = 'none';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function setStreaming(streaming) {
|
|
294
|
+
isStreaming = streaming;
|
|
295
|
+
typingEl.classList.toggle('visible', streaming);
|
|
296
|
+
sendBtn.disabled = streaming;
|
|
297
|
+
scrollToBottom();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function scrollToBottom() {
|
|
301
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function escapeHtml(str) {
|
|
305
|
+
return str
|
|
306
|
+
.replace(/&/g, '&')
|
|
307
|
+
.replace(/</g, '<')
|
|
308
|
+
.replace(/>/g, '>')
|
|
309
|
+
.replace(/"/g, '"')
|
|
310
|
+
.replace(/\n/g, '<br>');
|
|
311
|
+
}
|