Spaces:
Running
Split frontend script.js (5460 lines) into 8 focused modules
Browse files- utils.js: global state, apiFetch, escapeHtml, formatDate, 3 shared helpers
- timeline.js: sidebar timeline data + rendering + multi-user
- sessions.js: session CRUD + panel UI
- tabs.js: tab creation/switching, sendMessage
- streaming.js: SSE streaming, code cells, action widgets, parseMarkdown
- workspace.js: serialize/restore workspace state
- settings.js: settings CRUD, themes, debug/files/sessions panels
- app.js: DOMContentLoaded, initializeEventListeners
Deduplication: setupInputListeners (3 copies), setupCollapseToggle (4 copies),
closeAllPanels (4 verbose blocks) extracted to shared helpers in utils.js.
Update README with DIRECT_TOOL_REGISTRY docs, key patterns & conventions
section for agent contributors, and updated file tree.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- README.md +57 -17
- backend/agents.py +1 -1
- frontend/app.js +319 -0
- frontend/index.html +8 -1
- frontend/script.js +0 -0
- frontend/sessions.js +348 -0
- frontend/settings.js +1439 -0
- frontend/streaming.js +1279 -0
- frontend/tabs.js +573 -0
- frontend/timeline.js +519 -0
- frontend/utils.js +238 -0
- frontend/workspace.js +641 -0
|
@@ -34,7 +34,14 @@ backend/
|
|
| 34 |
|
| 35 |
frontend/
|
| 36 |
βββ index.html # Entry point
|
| 37 |
-
βββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
βββ style.css # All styles (CSS custom properties for theming)
|
| 39 |
βββ research-ui.js # Research-specific UI components
|
| 40 |
```
|
|
@@ -50,7 +57,7 @@ frontend/
|
|
| 50 |
|
| 51 |
### Adding a New Agent
|
| 52 |
|
| 53 |
-
|
| 54 |
|
| 55 |
**1. Backend registry** β add to `AGENT_REGISTRY` in `backend/agents.py`:
|
| 56 |
|
|
@@ -125,9 +132,9 @@ elif request.agent_type == "my_agent":
|
|
| 125 |
|
| 126 |
### Adding a Direct Tool
|
| 127 |
|
| 128 |
-
Direct tools execute synchronously in the command center (no sub-agent spawned).
|
| 129 |
|
| 130 |
-
**1. Define the tool** in `backend/tools.py`:
|
| 131 |
|
| 132 |
```python
|
| 133 |
my_tool = {
|
|
@@ -145,25 +152,25 @@ my_tool = {
|
|
| 145 |
}
|
| 146 |
}
|
| 147 |
|
| 148 |
-
def execute_my_tool(input: str) -> dict:
|
| 149 |
return {"content": "Result text for the LLM", "extra_data": "..."}
|
| 150 |
```
|
| 151 |
|
| 152 |
-
**2. Register it** in `backend/
|
| 153 |
|
| 154 |
```python
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
```
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
```python
|
| 164 |
-
if function_name == "my_tool":
|
| 165 |
-
result = execute_my_tool(args.get("input"))
|
| 166 |
-
```
|
| 167 |
|
| 168 |
### Modifying System Prompts
|
| 169 |
|
|
@@ -189,7 +196,7 @@ Settings are stored in `workspace/settings.json` and managed via the Settings pa
|
|
| 189 |
|
| 190 |
### Creating a Theme
|
| 191 |
|
| 192 |
-
Themes are CSS custom property sets defined in `frontend/
|
| 193 |
|
| 194 |
**Add to `themeColors` object** (search for `const themeColors`):
|
| 195 |
|
|
@@ -252,15 +259,48 @@ All agents communicate via Server-Sent Events. Each event is a JSON object with
|
|
| 252 |
| `code_start` | `{code}` β code execution started |
|
| 253 |
| `code` | `{output, error, images}` β code execution result |
|
| 254 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
## Testing
|
| 256 |
|
| 257 |
```bash
|
|
|
|
| 258 |
make test # Run all tests
|
| 259 |
make test-backend # Backend API tests (pytest)
|
| 260 |
make test-frontend # Frontend unit tests (vitest)
|
| 261 |
make test-e2e # E2E browser tests (playwright)
|
| 262 |
```
|
| 263 |
|
|
|
|
|
|
|
| 264 |
## Deployment
|
| 265 |
|
| 266 |
The app runs as a Docker container (designed for HuggingFace Spaces):
|
|
|
|
| 34 |
|
| 35 |
frontend/
|
| 36 |
βββ index.html # Entry point
|
| 37 |
+
βββ utils.js # Global state, shared helpers (setupInputListeners, closeAllPanels)
|
| 38 |
+
βββ timeline.js # Sidebar timeline data + rendering
|
| 39 |
+
βββ sessions.js # Session CRUD + panel
|
| 40 |
+
βββ tabs.js # Tab creation/switching, sendMessage
|
| 41 |
+
βββ streaming.js # SSE streaming, code cells, action widgets, markdown
|
| 42 |
+
βββ workspace.js # Workspace serialize/restore
|
| 43 |
+
βββ settings.js # Settings CRUD, themes, debug/files/sessions panels
|
| 44 |
+
βββ app.js # Initialization, event listeners, DOMContentLoaded
|
| 45 |
βββ style.css # All styles (CSS custom properties for theming)
|
| 46 |
βββ research-ui.js # Research-specific UI components
|
| 47 |
```
|
|
|
|
| 57 |
|
| 58 |
### Adding a New Agent
|
| 59 |
|
| 60 |
+
Only the backend needs changes β the frontend fetches the registry from `GET /api/agents` at startup.
|
| 61 |
|
| 62 |
**1. Backend registry** β add to `AGENT_REGISTRY` in `backend/agents.py`:
|
| 63 |
|
|
|
|
| 132 |
|
| 133 |
### Adding a Direct Tool
|
| 134 |
|
| 135 |
+
Direct tools execute synchronously in the command center (no sub-agent spawned). Only two files need changes.
|
| 136 |
|
| 137 |
+
**1. Define the tool schema + execute function** in `backend/tools.py`:
|
| 138 |
|
| 139 |
```python
|
| 140 |
my_tool = {
|
|
|
|
| 152 |
}
|
| 153 |
}
|
| 154 |
|
| 155 |
+
def execute_my_tool(input: str, files_root: str = None) -> dict:
|
| 156 |
return {"content": "Result text for the LLM", "extra_data": "..."}
|
| 157 |
```
|
| 158 |
|
| 159 |
+
**2. Register it** in `DIRECT_TOOL_REGISTRY` at the bottom of `backend/tools.py`:
|
| 160 |
|
| 161 |
```python
|
| 162 |
+
DIRECT_TOOL_REGISTRY = {
|
| 163 |
+
"show_html": { ... }, # existing
|
| 164 |
+
"my_tool": {
|
| 165 |
+
"schema": my_tool,
|
| 166 |
+
"execute": lambda args, ctx: execute_my_tool(
|
| 167 |
+
args.get("input", ""), files_root=ctx.get("files_root")
|
| 168 |
+
),
|
| 169 |
+
},
|
| 170 |
+
}
|
| 171 |
```
|
| 172 |
|
| 173 |
+
That's it β `command.py` automatically picks up tools from the registry.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
### Modifying System Prompts
|
| 176 |
|
|
|
|
| 196 |
|
| 197 |
### Creating a Theme
|
| 198 |
|
| 199 |
+
Themes are CSS custom property sets defined in `frontend/settings.js`.
|
| 200 |
|
| 201 |
**Add to `themeColors` object** (search for `const themeColors`):
|
| 202 |
|
|
|
|
| 259 |
| `code_start` | `{code}` β code execution started |
|
| 260 |
| `code` | `{output, error, images}` β code execution result |
|
| 261 |
|
| 262 |
+
## Key Patterns & Conventions
|
| 263 |
+
|
| 264 |
+
### Backend
|
| 265 |
+
|
| 266 |
+
- **Single source of truth**: `AGENT_REGISTRY` in `backend/agents.py` defines all agent types. The frontend fetches it via `GET /api/agents` β never duplicate agent definitions.
|
| 267 |
+
- **LLM calls**: Always use `call_llm()` from `agents.py` β it handles retries, abort checking, and emits `debug_call_input`/`debug_call_output` events for the debug panel.
|
| 268 |
+
- **Streaming pattern**: Agent handlers are sync generators yielding event dicts. `_stream_sync_generator()` in `main.py` wraps them for async SSE delivery β never duplicate the async queue boilerplate.
|
| 269 |
+
- **Direct tools**: `DIRECT_TOOL_REGISTRY` in `tools.py` maps tool name β `{schema, execute}`. `command.py` dispatches automatically.
|
| 270 |
+
- **Result nudging**: When an agent finishes without `<result>` tags, `nudge_for_result()` in `agents.py` asks the LLM for a final answer. It uses `call_llm` internally.
|
| 271 |
+
|
| 272 |
+
### Frontend
|
| 273 |
+
|
| 274 |
+
- **No build system**: Plain `<script>` tags in `index.html`, no bundler. Files share `window` scope.
|
| 275 |
+
- **Load order matters**: `utils.js` loads first (declares all globals), then other files. Cross-file function calls are fine because they happen at runtime, not parse time.
|
| 276 |
+
- **Global state** lives in `utils.js`: `AGENT_REGISTRY`, `settings`, `activeTabId`, `tabCounter`, `timelineData`, `debugHistory`, `globalFigureRegistry`, etc.
|
| 277 |
+
- **Shared helpers** (also in `utils.js`):
|
| 278 |
+
- `setupInputListeners(container, tabId)` β wires textarea auto-resize, Enter-to-send, send button click
|
| 279 |
+
- `setupCollapseToggle(cell, labelSelector)` β wires click-to-collapse on tool/code cells
|
| 280 |
+
- `closeAllPanels()` β closes all right-side panels (settings, debug, files, sessions)
|
| 281 |
+
- **Markdown rendering**: `parseMarkdown()` in `streaming.js` is the single entry point (marked + KaTeX + Prism).
|
| 282 |
+
- **Panel toggle pattern**: Call `closeAllPanels()` first, then add `.active` to the panel being opened.
|
| 283 |
+
- **Workspace persistence**: Changes auto-save via `saveWorkspaceDebounced()`. Tab state is serialized to JSON and posted to `/api/workspace`.
|
| 284 |
+
- **Cache busting**: Bump `?v=N` query params in `index.html` when changing JS/CSS files.
|
| 285 |
+
|
| 286 |
+
### Naming
|
| 287 |
+
|
| 288 |
+
- Backend: `stream_<agent>_execution()` for the sync generator, `_stream_<agent>_inner()` for the async wrapper in `main.py`
|
| 289 |
+
- Frontend: Agent types use short keys (`code`, `agent`, `research`, `image`, `command`)
|
| 290 |
+
- CSS: `--theme-*` for accent colors, `--bg-*` / `--text-*` / `--border-*` for surface colors
|
| 291 |
+
|
| 292 |
## Testing
|
| 293 |
|
| 294 |
```bash
|
| 295 |
+
make dev # Start server at http://localhost:8765
|
| 296 |
make test # Run all tests
|
| 297 |
make test-backend # Backend API tests (pytest)
|
| 298 |
make test-frontend # Frontend unit tests (vitest)
|
| 299 |
make test-e2e # E2E browser tests (playwright)
|
| 300 |
```
|
| 301 |
|
| 302 |
+
Verify backend imports: `python -c "from backend.command import stream_command_center"`
|
| 303 |
+
|
| 304 |
## Deployment
|
| 305 |
|
| 306 |
The app runs as a Docker container (designed for HuggingFace Spaces):
|
|
@@ -4,7 +4,7 @@ Centralized Agent Type Registry.
|
|
| 4 |
Adding a new agent type:
|
| 5 |
1. Add an entry to AGENT_REGISTRY below
|
| 6 |
2. Implement its streaming handler (or use "builtin:chat" for simple LLM proxy)
|
| 7 |
-
|
| 8 |
|
| 9 |
SSE Event Protocol β every handler emits `data: {JSON}\\n\\n` lines with a `type` field.
|
| 10 |
|
|
|
|
| 4 |
Adding a new agent type:
|
| 5 |
1. Add an entry to AGENT_REGISTRY below
|
| 6 |
2. Implement its streaming handler (or use "builtin:chat" for simple LLM proxy)
|
| 7 |
+
The frontend fetches the registry from GET /api/agents at startup β no JS changes needed.
|
| 8 |
|
| 9 |
SSE Event Protocol β every handler emits `data: {JSON}\\n\\n` lines with a `type` field.
|
| 10 |
|
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Initialize
|
| 2 |
+
document.addEventListener('DOMContentLoaded', async () => {
|
| 3 |
+
// Check if multi-user mode is enabled (retry if server not ready yet)
|
| 4 |
+
for (let attempt = 0; attempt < 5; attempt++) {
|
| 5 |
+
try {
|
| 6 |
+
const configResp = await fetch('/api/config');
|
| 7 |
+
const config = await configResp.json();
|
| 8 |
+
IS_MULTI_USER = config.multiUser;
|
| 9 |
+
if (config.multiUser && !SESSION_ID) {
|
| 10 |
+
await showUsernameOverlay();
|
| 11 |
+
}
|
| 12 |
+
break;
|
| 13 |
+
} catch (e) {
|
| 14 |
+
if (attempt < 4) {
|
| 15 |
+
await new Promise(r => setTimeout(r, 1000));
|
| 16 |
+
}
|
| 17 |
+
// After all retries fail, continue in single-user mode
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// Fetch agent registry from backend (single source of truth)
|
| 22 |
+
try {
|
| 23 |
+
const agentsResp = await apiFetch('/api/agents');
|
| 24 |
+
const agentsData = await agentsResp.json();
|
| 25 |
+
for (const agent of agentsData.agents) {
|
| 26 |
+
AGENT_REGISTRY[agent.key] = agent;
|
| 27 |
+
}
|
| 28 |
+
} catch (e) {
|
| 29 |
+
console.error('Failed to fetch agent registry, using fallback');
|
| 30 |
+
// Minimal fallback so the app still works if backend is slow
|
| 31 |
+
AGENT_REGISTRY = {
|
| 32 |
+
command: { label: 'MAIN', hasCounter: false, inMenu: false, inLauncher: false, placeholder: 'Enter message...' },
|
| 33 |
+
agent: { label: 'AGENT', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
|
| 34 |
+
code: { label: 'CODE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
|
| 35 |
+
research: { label: 'RESEARCH', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
|
| 36 |
+
image: { label: 'IMAGE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Describe an image or paste a URL...' },
|
| 37 |
+
};
|
| 38 |
+
}
|
| 39 |
+
// Initialize settings.agents with registry keys (before loadSettings merges saved values)
|
| 40 |
+
settings.agents = Object.fromEntries(Object.keys(AGENT_REGISTRY).map(k => [k, '']));
|
| 41 |
+
|
| 42 |
+
await loadSettings();
|
| 43 |
+
applyTheme(settings.themeColor || 'forest');
|
| 44 |
+
initializeEventListeners();
|
| 45 |
+
initializeSessionListeners();
|
| 46 |
+
updateUserIndicator();
|
| 47 |
+
|
| 48 |
+
// Always show session selector on load (don't auto-resume last session)
|
| 49 |
+
const sessionsData = await fetchSessions();
|
| 50 |
+
showSessionSelector(sessionsData.sessions);
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
// ============================================================
|
| 54 |
+
// Dynamic HTML generation from AGENT_REGISTRY
|
| 55 |
+
// ============================================================
|
| 56 |
+
|
| 57 |
+
function renderNewTabMenu() {
|
| 58 |
+
const menu = document.getElementById('newTabMenu');
|
| 59 |
+
if (!menu) return;
|
| 60 |
+
menu.innerHTML = '';
|
| 61 |
+
for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
|
| 62 |
+
if (!agent.inMenu) continue;
|
| 63 |
+
const item = document.createElement('div');
|
| 64 |
+
item.className = 'menu-item';
|
| 65 |
+
item.dataset.type = key;
|
| 66 |
+
item.textContent = agent.label;
|
| 67 |
+
menu.appendChild(item);
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function renderLauncherButtons() {
|
| 72 |
+
const container = document.getElementById('launcherButtons');
|
| 73 |
+
if (!container) return;
|
| 74 |
+
// Insert before the debug button (keep it at the end)
|
| 75 |
+
const debugBtn = container.querySelector('#debugBtn');
|
| 76 |
+
for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
|
| 77 |
+
if (!agent.inLauncher) continue;
|
| 78 |
+
const btn = document.createElement('button');
|
| 79 |
+
btn.className = 'launcher-btn';
|
| 80 |
+
btn.dataset.type = key;
|
| 81 |
+
btn.textContent = agent.label;
|
| 82 |
+
container.insertBefore(btn, debugBtn);
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function renderAgentModelSelectors() {
|
| 87 |
+
const grid = document.getElementById('agentModelsGrid');
|
| 88 |
+
if (!grid) return;
|
| 89 |
+
grid.innerHTML = '';
|
| 90 |
+
for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
|
| 91 |
+
const label = document.createElement('label');
|
| 92 |
+
label.textContent = `${agent.label}:`;
|
| 93 |
+
const select = document.createElement('select');
|
| 94 |
+
select.id = `setting-agent-${key}`;
|
| 95 |
+
select.className = 'settings-select';
|
| 96 |
+
grid.appendChild(label);
|
| 97 |
+
grid.appendChild(select);
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function initializeEventListeners() {
|
| 102 |
+
// Generate dynamic UI elements from registry
|
| 103 |
+
renderLauncherButtons();
|
| 104 |
+
renderNewTabMenu();
|
| 105 |
+
renderAgentModelSelectors();
|
| 106 |
+
|
| 107 |
+
// Launcher buttons in command center
|
| 108 |
+
document.querySelectorAll('.launcher-btn').forEach(btn => {
|
| 109 |
+
btn.addEventListener('click', (e) => {
|
| 110 |
+
e.stopPropagation();
|
| 111 |
+
const type = btn.dataset.type;
|
| 112 |
+
createAgentTab(type);
|
| 113 |
+
});
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
// Command center chat functionality
|
| 117 |
+
const commandContent = document.querySelector('.tab-content[data-content-id="0"]');
|
| 118 |
+
if (commandContent) {
|
| 119 |
+
setupInputListeners(commandContent, 0);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// Sidebar checkboxes
|
| 123 |
+
const compactViewCheckbox = document.getElementById('compactViewCheckbox');
|
| 124 |
+
const collapseAgentsCheckbox = document.getElementById('collapseAgentsCheckbox');
|
| 125 |
+
const collapseToolsCheckbox = document.getElementById('collapseToolsCheckbox');
|
| 126 |
+
|
| 127 |
+
// Compact view: affects timeline only (collapse agents + hide turns)
|
| 128 |
+
if (compactViewCheckbox) {
|
| 129 |
+
compactViewCheckbox.addEventListener('change', () => {
|
| 130 |
+
const compact = compactViewCheckbox.checked;
|
| 131 |
+
if (compact) {
|
| 132 |
+
// Collapse all agent boxes in timeline
|
| 133 |
+
Object.entries(timelineData).forEach(([id, data]) => {
|
| 134 |
+
if (data.parentTabId !== null) {
|
| 135 |
+
collapsedAgents.add(String(id));
|
| 136 |
+
}
|
| 137 |
+
});
|
| 138 |
+
showAllTurns = false;
|
| 139 |
+
} else {
|
| 140 |
+
collapsedAgents.clear();
|
| 141 |
+
showAllTurns = true;
|
| 142 |
+
}
|
| 143 |
+
renderTimeline();
|
| 144 |
+
});
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// Chat collapse: agents β affects action-widgets in chat only
|
| 148 |
+
if (collapseAgentsCheckbox) {
|
| 149 |
+
collapseAgentsCheckbox.addEventListener('change', () => {
|
| 150 |
+
const collapse = collapseAgentsCheckbox.checked;
|
| 151 |
+
document.querySelectorAll('.action-widget').forEach(w => {
|
| 152 |
+
const toggle = w.querySelector('.widget-collapse-toggle');
|
| 153 |
+
if (collapse) {
|
| 154 |
+
w.classList.add('collapsed');
|
| 155 |
+
if (toggle) toggle.classList.add('collapsed');
|
| 156 |
+
} else {
|
| 157 |
+
w.classList.remove('collapsed');
|
| 158 |
+
if (toggle) toggle.classList.remove('collapsed');
|
| 159 |
+
}
|
| 160 |
+
});
|
| 161 |
+
});
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// Chat collapse: tools β affects tool-cells in chat only
|
| 165 |
+
if (collapseToolsCheckbox) {
|
| 166 |
+
collapseToolsCheckbox.addEventListener('change', () => {
|
| 167 |
+
const collapse = collapseToolsCheckbox.checked;
|
| 168 |
+
document.querySelectorAll('.tool-cell').forEach(w => {
|
| 169 |
+
const toggle = w.querySelector('.widget-collapse-toggle');
|
| 170 |
+
if (collapse) {
|
| 171 |
+
w.classList.add('collapsed');
|
| 172 |
+
if (toggle) toggle.classList.add('collapsed');
|
| 173 |
+
} else {
|
| 174 |
+
w.classList.remove('collapsed');
|
| 175 |
+
if (toggle) toggle.classList.remove('collapsed');
|
| 176 |
+
}
|
| 177 |
+
});
|
| 178 |
+
});
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// New tab button - toggle menu
|
| 182 |
+
const newTabBtn = document.getElementById('newTabBtn');
|
| 183 |
+
const newTabMenu = document.getElementById('newTabMenu');
|
| 184 |
+
|
| 185 |
+
if (newTabBtn && newTabMenu) {
|
| 186 |
+
newTabBtn.addEventListener('click', (e) => {
|
| 187 |
+
e.stopPropagation();
|
| 188 |
+
e.preventDefault();
|
| 189 |
+
|
| 190 |
+
// Position the menu below the button
|
| 191 |
+
const rect = newTabBtn.getBoundingClientRect();
|
| 192 |
+
newTabMenu.style.top = `${rect.bottom}px`;
|
| 193 |
+
newTabMenu.style.left = `${rect.left}px`;
|
| 194 |
+
|
| 195 |
+
newTabMenu.classList.toggle('active');
|
| 196 |
+
});
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
// Close menu when clicking outside
|
| 200 |
+
document.addEventListener('click', (e) => {
|
| 201 |
+
if (!e.target.closest('.new-tab-wrapper')) {
|
| 202 |
+
newTabMenu.classList.remove('active');
|
| 203 |
+
}
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
// Menu items
|
| 207 |
+
document.querySelectorAll('.menu-item').forEach(item => {
|
| 208 |
+
item.addEventListener('click', () => {
|
| 209 |
+
const type = item.dataset.type;
|
| 210 |
+
createAgentTab(type);
|
| 211 |
+
newTabMenu.classList.remove('active');
|
| 212 |
+
});
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
// Tab switching
|
| 216 |
+
document.addEventListener('click', (e) => {
|
| 217 |
+
const tab = e.target.closest('.tab');
|
| 218 |
+
if (tab && !e.target.closest('.new-tab-wrapper')) {
|
| 219 |
+
if (e.target.classList.contains('tab-close')) {
|
| 220 |
+
closeTab(parseInt(tab.dataset.tabId));
|
| 221 |
+
} else {
|
| 222 |
+
switchToTab(parseInt(tab.dataset.tabId));
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
// Settings button - now handled in settings panel section below
|
| 228 |
+
// (removed old openSettings() call)
|
| 229 |
+
|
| 230 |
+
// Save settings button
|
| 231 |
+
const saveSettingsBtn = document.getElementById('saveSettingsBtn');
|
| 232 |
+
if (saveSettingsBtn) {
|
| 233 |
+
saveSettingsBtn.addEventListener('click', () => {
|
| 234 |
+
saveSettings();
|
| 235 |
+
});
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// Cancel settings button
|
| 239 |
+
const cancelSettingsBtn = document.getElementById('cancelSettingsBtn');
|
| 240 |
+
if (cancelSettingsBtn) {
|
| 241 |
+
cancelSettingsBtn.addEventListener('click', () => closeAllPanels());
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
// Theme color picker
|
| 245 |
+
const themePicker = document.getElementById('theme-color-picker');
|
| 246 |
+
if (themePicker) {
|
| 247 |
+
themePicker.addEventListener('click', (e) => {
|
| 248 |
+
const themeOption = e.target.closest('.theme-option');
|
| 249 |
+
if (themeOption) {
|
| 250 |
+
// Remove selected from all
|
| 251 |
+
themePicker.querySelectorAll('.theme-option').forEach(opt => {
|
| 252 |
+
opt.classList.remove('selected');
|
| 253 |
+
});
|
| 254 |
+
// Add selected to clicked
|
| 255 |
+
themeOption.classList.add('selected');
|
| 256 |
+
// Update hidden input
|
| 257 |
+
const themeValue = themeOption.dataset.theme;
|
| 258 |
+
document.getElementById('setting-theme-color').value = themeValue;
|
| 259 |
+
}
|
| 260 |
+
});
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// Double-click on collapse toggle: collapse/uncollapse all widgets in the same chat
|
| 264 |
+
document.addEventListener('dblclick', (e) => {
|
| 265 |
+
const toggle = e.target.closest('.widget-collapse-toggle');
|
| 266 |
+
if (!toggle) return;
|
| 267 |
+
e.stopPropagation();
|
| 268 |
+
const chatContainer = toggle.closest('.chat-container');
|
| 269 |
+
if (!chatContainer) return;
|
| 270 |
+
const widgets = chatContainer.querySelectorAll('.tool-cell, .code-cell, .action-widget');
|
| 271 |
+
if (widgets.length === 0) return;
|
| 272 |
+
// If clicked widget is now collapsed, collapse all; otherwise uncollapse all
|
| 273 |
+
const clickedWidget = toggle.closest('.tool-cell, .code-cell, .action-widget');
|
| 274 |
+
const shouldCollapse = !clickedWidget?.classList.contains('collapsed');
|
| 275 |
+
widgets.forEach(w => {
|
| 276 |
+
w.classList.toggle('collapsed', shouldCollapse);
|
| 277 |
+
const t = w.querySelector('.widget-collapse-toggle');
|
| 278 |
+
if (t) t.classList.toggle('collapsed', shouldCollapse);
|
| 279 |
+
});
|
| 280 |
+
});
|
| 281 |
+
|
| 282 |
+
// Resizable sidebar
|
| 283 |
+
const sidebar = document.getElementById('agentsSidebar');
|
| 284 |
+
const resizeHandle = document.getElementById('sidebarResizeHandle');
|
| 285 |
+
if (sidebar && resizeHandle) {
|
| 286 |
+
const savedWidth = localStorage.getItem('agentui_sidebar_width');
|
| 287 |
+
if (savedWidth) {
|
| 288 |
+
const w = parseInt(savedWidth);
|
| 289 |
+
if (w >= 140 && w <= 400) {
|
| 290 |
+
sidebar.style.setProperty('--sidebar-width', w + 'px');
|
| 291 |
+
sidebar.style.width = w + 'px';
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
let dragging = false;
|
| 296 |
+
resizeHandle.addEventListener('mousedown', (e) => {
|
| 297 |
+
e.preventDefault();
|
| 298 |
+
dragging = true;
|
| 299 |
+
resizeHandle.classList.add('dragging');
|
| 300 |
+
document.body.style.cursor = 'col-resize';
|
| 301 |
+
document.body.style.userSelect = 'none';
|
| 302 |
+
});
|
| 303 |
+
document.addEventListener('mousemove', (e) => {
|
| 304 |
+
if (!dragging) return;
|
| 305 |
+
const w = Math.min(400, Math.max(140, e.clientX));
|
| 306 |
+
sidebar.style.setProperty('--sidebar-width', w + 'px');
|
| 307 |
+
sidebar.style.width = w + 'px';
|
| 308 |
+
});
|
| 309 |
+
document.addEventListener('mouseup', () => {
|
| 310 |
+
if (!dragging) return;
|
| 311 |
+
dragging = false;
|
| 312 |
+
resizeHandle.classList.remove('dragging');
|
| 313 |
+
document.body.style.cursor = '';
|
| 314 |
+
document.body.style.userSelect = '';
|
| 315 |
+
const w = parseInt(sidebar.style.width);
|
| 316 |
+
if (w) localStorage.setItem('agentui_sidebar_width', w);
|
| 317 |
+
});
|
| 318 |
+
}
|
| 319 |
+
}
|
|
@@ -504,7 +504,14 @@
|
|
| 504 |
</div>
|
| 505 |
</div>
|
| 506 |
|
|
|
|
| 507 |
<script src="research-ui.js?v=23"></script>
|
| 508 |
-
<script src="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
</body>
|
| 510 |
</html>
|
|
|
|
| 504 |
</div>
|
| 505 |
</div>
|
| 506 |
|
| 507 |
+
<script src="utils.js?v=1"></script>
|
| 508 |
<script src="research-ui.js?v=23"></script>
|
| 509 |
+
<script src="timeline.js?v=1"></script>
|
| 510 |
+
<script src="sessions.js?v=1"></script>
|
| 511 |
+
<script src="streaming.js?v=1"></script>
|
| 512 |
+
<script src="tabs.js?v=1"></script>
|
| 513 |
+
<script src="workspace.js?v=1"></script>
|
| 514 |
+
<script src="settings.js?v=1"></script>
|
| 515 |
+
<script src="app.js?v=1"></script>
|
| 516 |
</body>
|
| 517 |
</html>
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================
|
| 2 |
+
// Session Management
|
| 3 |
+
// ============================================
|
| 4 |
+
|
| 5 |
+
async function fetchSessions() {
|
| 6 |
+
try {
|
| 7 |
+
const response = await apiFetch('/api/sessions');
|
| 8 |
+
if (response.ok) {
|
| 9 |
+
return await response.json();
|
| 10 |
+
}
|
| 11 |
+
} catch (e) {
|
| 12 |
+
console.error('Failed to fetch sessions:', e);
|
| 13 |
+
}
|
| 14 |
+
return { sessions: [], current: null };
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function showSessionSelector(sessions) {
|
| 18 |
+
const selector = document.getElementById('sessionSelector');
|
| 19 |
+
const welcome = document.getElementById('welcomeMessage');
|
| 20 |
+
const sessionIndicator = document.getElementById('sessionIndicator');
|
| 21 |
+
const inputArea = document.getElementById('commandInputArea');
|
| 22 |
+
|
| 23 |
+
// Show welcome message and session selector
|
| 24 |
+
if (welcome) welcome.style.display = 'block';
|
| 25 |
+
if (selector) selector.style.display = 'block';
|
| 26 |
+
if (sessionIndicator) sessionIndicator.style.display = 'none';
|
| 27 |
+
if (inputArea) inputArea.style.display = 'none';
|
| 28 |
+
|
| 29 |
+
// Populate existing sessions dropdown
|
| 30 |
+
const select = document.getElementById('existingSessionSelect');
|
| 31 |
+
if (select) {
|
| 32 |
+
select.innerHTML = '<option value="">-- Select session --</option>';
|
| 33 |
+
sessions.forEach(session => {
|
| 34 |
+
const option = document.createElement('option');
|
| 35 |
+
option.value = session.name;
|
| 36 |
+
option.textContent = `${session.name} (${formatDate(session.modified)})`;
|
| 37 |
+
select.appendChild(option);
|
| 38 |
+
});
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function hideSessionSelector() {
|
| 43 |
+
const selector = document.getElementById('sessionSelector');
|
| 44 |
+
const welcome = document.getElementById('welcomeMessage');
|
| 45 |
+
const sessionIndicator = document.getElementById('sessionIndicator');
|
| 46 |
+
const inputArea = document.getElementById('commandInputArea');
|
| 47 |
+
|
| 48 |
+
// Hide session selector, keep welcome visible, show session indicator and input
|
| 49 |
+
if (selector) selector.style.display = 'none';
|
| 50 |
+
if (welcome) welcome.style.display = 'block';
|
| 51 |
+
if (sessionIndicator) sessionIndicator.style.display = 'block';
|
| 52 |
+
if (inputArea) inputArea.style.display = 'block';
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async function onSessionSelected(sessionName) {
|
| 56 |
+
currentSession = sessionName;
|
| 57 |
+
hideSessionSelector();
|
| 58 |
+
|
| 59 |
+
// Update session name display
|
| 60 |
+
const nameEl = document.getElementById('currentSessionName');
|
| 61 |
+
if (nameEl) nameEl.textContent = sessionName;
|
| 62 |
+
|
| 63 |
+
const renameInput = document.getElementById('currentSessionRename');
|
| 64 |
+
if (renameInput) renameInput.value = sessionName;
|
| 65 |
+
|
| 66 |
+
// Update timeline title to show session name
|
| 67 |
+
if (timelineData[0]) {
|
| 68 |
+
timelineData[0].title = sessionName;
|
| 69 |
+
renderTimeline();
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Load workspace for this session
|
| 73 |
+
await loadWorkspace();
|
| 74 |
+
|
| 75 |
+
// Refresh sessions panel list
|
| 76 |
+
await refreshSessionsList();
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
async function createSession(name) {
|
| 80 |
+
try {
|
| 81 |
+
const response = await apiFetch('/api/sessions', {
|
| 82 |
+
method: 'POST',
|
| 83 |
+
headers: { 'Content-Type': 'application/json' },
|
| 84 |
+
body: JSON.stringify({ name })
|
| 85 |
+
});
|
| 86 |
+
if (response.ok) {
|
| 87 |
+
const data = await response.json();
|
| 88 |
+
await onSessionSelected(data.name);
|
| 89 |
+
return true;
|
| 90 |
+
} else {
|
| 91 |
+
const error = await response.json();
|
| 92 |
+
alert(error.detail || 'Failed to create session');
|
| 93 |
+
}
|
| 94 |
+
} catch (e) {
|
| 95 |
+
console.error('Failed to create session:', e);
|
| 96 |
+
alert('Failed to create session');
|
| 97 |
+
}
|
| 98 |
+
return false;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
async function selectSession(name) {
|
| 102 |
+
try {
|
| 103 |
+
const response = await apiFetch('/api/sessions/select', {
|
| 104 |
+
method: 'POST',
|
| 105 |
+
headers: { 'Content-Type': 'application/json' },
|
| 106 |
+
body: JSON.stringify({ name })
|
| 107 |
+
});
|
| 108 |
+
if (response.ok) {
|
| 109 |
+
// Reset local state and load the selected session
|
| 110 |
+
resetLocalState();
|
| 111 |
+
await onSessionSelected(name);
|
| 112 |
+
return true;
|
| 113 |
+
} else {
|
| 114 |
+
const error = await response.json();
|
| 115 |
+
alert(error.detail || 'Failed to select session');
|
| 116 |
+
}
|
| 117 |
+
} catch (e) {
|
| 118 |
+
console.error('Failed to select session:', e);
|
| 119 |
+
alert('Failed to select session');
|
| 120 |
+
}
|
| 121 |
+
return false;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
async function renameSession(oldName, newName) {
|
| 125 |
+
try {
|
| 126 |
+
const response = await apiFetch('/api/sessions/rename', {
|
| 127 |
+
method: 'POST',
|
| 128 |
+
headers: { 'Content-Type': 'application/json' },
|
| 129 |
+
body: JSON.stringify({ oldName, newName })
|
| 130 |
+
});
|
| 131 |
+
if (response.ok) {
|
| 132 |
+
const data = await response.json();
|
| 133 |
+
currentSession = data.name;
|
| 134 |
+
|
| 135 |
+
// Update displays
|
| 136 |
+
const nameEl = document.getElementById('currentSessionName');
|
| 137 |
+
if (nameEl) nameEl.textContent = data.name;
|
| 138 |
+
|
| 139 |
+
// Update timeline title
|
| 140 |
+
if (timelineData[0]) {
|
| 141 |
+
timelineData[0].title = data.name;
|
| 142 |
+
renderTimeline();
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
await refreshSessionsList();
|
| 146 |
+
return true;
|
| 147 |
+
} else {
|
| 148 |
+
const error = await response.json();
|
| 149 |
+
alert(error.detail || 'Failed to rename session');
|
| 150 |
+
}
|
| 151 |
+
} catch (e) {
|
| 152 |
+
console.error('Failed to rename session:', e);
|
| 153 |
+
alert('Failed to rename session');
|
| 154 |
+
}
|
| 155 |
+
return false;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function openSessionsPanel() {
|
| 159 |
+
const sessionsBtn = document.getElementById('sessionsBtn');
|
| 160 |
+
if (sessionsBtn) {
|
| 161 |
+
sessionsBtn.click();
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
async function refreshSessionsList() {
|
| 166 |
+
const sessionsData = await fetchSessions();
|
| 167 |
+
const listEl = document.getElementById('sessionsList');
|
| 168 |
+
|
| 169 |
+
// Update the current session rename input
|
| 170 |
+
const renameInput = document.getElementById('currentSessionRename');
|
| 171 |
+
if (renameInput && currentSession) {
|
| 172 |
+
renameInput.value = currentSession;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
if (!listEl) return;
|
| 176 |
+
|
| 177 |
+
if (sessionsData.sessions.length === 0) {
|
| 178 |
+
listEl.innerHTML = '<div class="sessions-loading">No other sessions</div>';
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
listEl.innerHTML = '';
|
| 183 |
+
sessionsData.sessions.forEach(session => {
|
| 184 |
+
const item = document.createElement('div');
|
| 185 |
+
item.className = 'sessions-list-item' + (session.name === currentSession ? ' current' : '');
|
| 186 |
+
|
| 187 |
+
const isCurrent = session.name === currentSession;
|
| 188 |
+
item.innerHTML = `
|
| 189 |
+
<span class="sessions-list-item-name">${escapeHtml(session.name)}</span>
|
| 190 |
+
<span class="sessions-list-item-date">${formatDate(session.modified)}</span>
|
| 191 |
+
${!isCurrent ? `<button class="sessions-delete-btn" title="Delete session">Γ</button>` : ''}
|
| 192 |
+
`;
|
| 193 |
+
|
| 194 |
+
if (!isCurrent) {
|
| 195 |
+
// Click on name/date to select
|
| 196 |
+
item.querySelector('.sessions-list-item-name').addEventListener('click', () => selectSession(session.name));
|
| 197 |
+
item.querySelector('.sessions-list-item-date').addEventListener('click', () => selectSession(session.name));
|
| 198 |
+
|
| 199 |
+
// Click on delete button to delete
|
| 200 |
+
const deleteBtn = item.querySelector('.sessions-delete-btn');
|
| 201 |
+
if (deleteBtn) {
|
| 202 |
+
deleteBtn.addEventListener('click', (e) => {
|
| 203 |
+
e.stopPropagation();
|
| 204 |
+
deleteSession(session.name);
|
| 205 |
+
});
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
listEl.appendChild(item);
|
| 210 |
+
});
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
async function deleteSession(sessionName) {
|
| 214 |
+
if (!confirm(`Delete session "${sessionName}"? This cannot be undone.`)) {
|
| 215 |
+
return;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
try {
|
| 219 |
+
const response = await apiFetch(`/api/sessions/${encodeURIComponent(sessionName)}`, {
|
| 220 |
+
method: 'DELETE'
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
if (!response.ok) {
|
| 224 |
+
try {
|
| 225 |
+
const error = await response.json();
|
| 226 |
+
alert(error.detail || 'Failed to delete session');
|
| 227 |
+
} catch {
|
| 228 |
+
alert('Failed to delete session');
|
| 229 |
+
}
|
| 230 |
+
return;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
// Refresh the sessions list in the panel
|
| 234 |
+
refreshSessionsList();
|
| 235 |
+
|
| 236 |
+
// Also refresh the welcome page dropdown
|
| 237 |
+
const sessionsData = await fetchSessions();
|
| 238 |
+
const select = document.getElementById('existingSessionSelect');
|
| 239 |
+
if (select) {
|
| 240 |
+
select.innerHTML = '<option value="">-- Select session --</option>';
|
| 241 |
+
sessionsData.sessions.forEach(session => {
|
| 242 |
+
const option = document.createElement('option');
|
| 243 |
+
option.value = session.name;
|
| 244 |
+
option.textContent = `${session.name} (${formatDate(session.modified)})`;
|
| 245 |
+
select.appendChild(option);
|
| 246 |
+
});
|
| 247 |
+
}
|
| 248 |
+
} catch (error) {
|
| 249 |
+
console.error('Error deleting session:', error);
|
| 250 |
+
alert('Failed to delete session');
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
function initializeSessionListeners() {
|
| 256 |
+
// Welcome page session selector
|
| 257 |
+
const createBtn = document.getElementById('createSessionBtn');
|
| 258 |
+
const newNameInput = document.getElementById('newSessionName');
|
| 259 |
+
const existingSelect = document.getElementById('existingSessionSelect');
|
| 260 |
+
|
| 261 |
+
// Pre-populate with a cool random name
|
| 262 |
+
if (newNameInput) {
|
| 263 |
+
generateSessionName().then(name => newNameInput.value = name);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
// Regenerate button
|
| 267 |
+
const regenerateBtn = document.getElementById('regenerateNameBtn');
|
| 268 |
+
if (regenerateBtn && newNameInput) {
|
| 269 |
+
regenerateBtn.addEventListener('click', async () => {
|
| 270 |
+
newNameInput.value = await generateSessionName();
|
| 271 |
+
});
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
if (createBtn) {
|
| 275 |
+
createBtn.addEventListener('click', async () => {
|
| 276 |
+
const name = newNameInput?.value.trim();
|
| 277 |
+
if (name) {
|
| 278 |
+
createSession(name);
|
| 279 |
+
} else {
|
| 280 |
+
// Auto-generate name
|
| 281 |
+
createSession(await generateSessionName());
|
| 282 |
+
}
|
| 283 |
+
});
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
if (newNameInput) {
|
| 287 |
+
newNameInput.addEventListener('keydown', (e) => {
|
| 288 |
+
if (e.key === 'Enter') {
|
| 289 |
+
createBtn?.click();
|
| 290 |
+
}
|
| 291 |
+
});
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
if (existingSelect) {
|
| 295 |
+
existingSelect.addEventListener('change', () => {
|
| 296 |
+
const name = existingSelect.value;
|
| 297 |
+
if (name) {
|
| 298 |
+
selectSession(name);
|
| 299 |
+
}
|
| 300 |
+
});
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
// Sessions panel handlers are set up at the end of the file with other panels
|
| 304 |
+
|
| 305 |
+
// Panel rename button
|
| 306 |
+
const renameBtn = document.getElementById('renameSessionBtn');
|
| 307 |
+
const renameInput = document.getElementById('currentSessionRename');
|
| 308 |
+
|
| 309 |
+
if (renameBtn && renameInput) {
|
| 310 |
+
renameBtn.addEventListener('click', () => {
|
| 311 |
+
const newName = renameInput.value.trim();
|
| 312 |
+
if (newName && newName !== currentSession) {
|
| 313 |
+
renameSession(currentSession, newName);
|
| 314 |
+
}
|
| 315 |
+
});
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// Panel create new session
|
| 319 |
+
const panelCreateBtn = document.getElementById('panelCreateSessionBtn');
|
| 320 |
+
const panelNewNameInput = document.getElementById('panelNewSessionName');
|
| 321 |
+
const panelRegenerateBtn = document.getElementById('panelRegenerateNameBtn');
|
| 322 |
+
|
| 323 |
+
// Pre-populate panel input with cool name too
|
| 324 |
+
if (panelNewNameInput) {
|
| 325 |
+
generateSessionName().then(name => panelNewNameInput.value = name);
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// Panel regenerate button
|
| 329 |
+
if (panelRegenerateBtn && panelNewNameInput) {
|
| 330 |
+
panelRegenerateBtn.addEventListener('click', async () => {
|
| 331 |
+
panelNewNameInput.value = await generateSessionName();
|
| 332 |
+
});
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
if (panelCreateBtn) {
|
| 336 |
+
panelCreateBtn.addEventListener('click', async () => {
|
| 337 |
+
const name = panelNewNameInput?.value.trim();
|
| 338 |
+
if (name) {
|
| 339 |
+
resetLocalState();
|
| 340 |
+
await createSession(name);
|
| 341 |
+
// Pre-populate a new name for next time
|
| 342 |
+
if (panelNewNameInput) {
|
| 343 |
+
panelNewNameInput.value = await generateSessionName();
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
});
|
| 347 |
+
}
|
| 348 |
+
}
|
|
@@ -0,0 +1,1439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================
|
| 2 |
+
// Settings management
|
| 3 |
+
// ============================================
|
| 4 |
+
|
| 5 |
+
// Migrate old settings format (v1) to new format (v2)
|
| 6 |
+
function migrateSettings(oldSettings) {
|
| 7 |
+
// Already migrated or new format
|
| 8 |
+
if (oldSettings.settingsVersion >= 2) {
|
| 9 |
+
return oldSettings;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
console.log('Migrating settings from v1 to v2...');
|
| 13 |
+
|
| 14 |
+
const newSettings = {
|
| 15 |
+
providers: {},
|
| 16 |
+
models: {},
|
| 17 |
+
agents: {
|
| 18 |
+
command: '',
|
| 19 |
+
agent: '',
|
| 20 |
+
code: '',
|
| 21 |
+
research: '',
|
| 22 |
+
chat: ''
|
| 23 |
+
},
|
| 24 |
+
e2bKey: oldSettings.e2bKey || '',
|
| 25 |
+
serperKey: oldSettings.serperKey || '',
|
| 26 |
+
hfToken: oldSettings.hfToken || '',
|
| 27 |
+
imageGenModel: oldSettings.imageGenModel || '',
|
| 28 |
+
imageEditModel: oldSettings.imageEditModel || '',
|
| 29 |
+
researchSubAgentModel: oldSettings.researchSubAgentModel || '',
|
| 30 |
+
researchParallelWorkers: oldSettings.researchParallelWorkers || null,
|
| 31 |
+
researchMaxWebsites: oldSettings.researchMaxWebsites || null,
|
| 32 |
+
themeColor: oldSettings.themeColor || 'forest',
|
| 33 |
+
settingsVersion: 2
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
// Create a default provider from old endpoint/token if they exist
|
| 37 |
+
if (oldSettings.endpoint) {
|
| 38 |
+
const providerId = 'provider_default';
|
| 39 |
+
newSettings.providers[providerId] = {
|
| 40 |
+
name: 'Default',
|
| 41 |
+
endpoint: oldSettings.endpoint,
|
| 42 |
+
token: oldSettings.token || ''
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
// Create a default model if old model exists
|
| 46 |
+
if (oldSettings.model) {
|
| 47 |
+
const modelId = 'model_default';
|
| 48 |
+
newSettings.models[modelId] = {
|
| 49 |
+
name: oldSettings.model,
|
| 50 |
+
providerId: providerId,
|
| 51 |
+
modelId: oldSettings.model
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
// Set as default for all agents
|
| 55 |
+
newSettings.agents.command = modelId;
|
| 56 |
+
newSettings.agents.agent = modelId;
|
| 57 |
+
newSettings.agents.code = modelId;
|
| 58 |
+
newSettings.agents.research = modelId;
|
| 59 |
+
newSettings.agents.chat = modelId;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Migrate agent-specific models if they existed
|
| 63 |
+
const oldModels = oldSettings.models || {};
|
| 64 |
+
const agentTypes = Object.keys(AGENT_REGISTRY).filter(k => AGENT_REGISTRY[k].hasCounter);
|
| 65 |
+
agentTypes.forEach(type => {
|
| 66 |
+
if (oldModels[type]) {
|
| 67 |
+
const specificModelId = `model_${type}`;
|
| 68 |
+
newSettings.models[specificModelId] = {
|
| 69 |
+
name: `${type.charAt(0).toUpperCase() + type.slice(1)} - ${oldModels[type]}`,
|
| 70 |
+
providerId: providerId,
|
| 71 |
+
modelId: oldModels[type]
|
| 72 |
+
};
|
| 73 |
+
newSettings.agents[type] = specificModelId;
|
| 74 |
+
}
|
| 75 |
+
});
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
console.log('Settings migrated:', newSettings);
|
| 79 |
+
return newSettings;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
async function loadSettings() {
|
| 83 |
+
let loadedSettings = null;
|
| 84 |
+
|
| 85 |
+
// Try to load from backend API (file-based) first
|
| 86 |
+
try {
|
| 87 |
+
const response = await apiFetch('/api/settings');
|
| 88 |
+
if (response.ok) {
|
| 89 |
+
loadedSettings = await response.json();
|
| 90 |
+
console.log('Settings loaded from file:', loadedSettings);
|
| 91 |
+
}
|
| 92 |
+
} catch (e) {
|
| 93 |
+
console.log('Could not load settings from backend, falling back to localStorage');
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Fallback to localStorage if backend is unavailable
|
| 97 |
+
if (!loadedSettings) {
|
| 98 |
+
const savedSettings = localStorage.getItem('agentui_settings') || localStorage.getItem('productive_settings');
|
| 99 |
+
console.log('Loading settings from localStorage:', savedSettings ? 'found' : 'not found');
|
| 100 |
+
if (savedSettings) {
|
| 101 |
+
try {
|
| 102 |
+
loadedSettings = JSON.parse(savedSettings);
|
| 103 |
+
console.log('Settings loaded from localStorage:', loadedSettings);
|
| 104 |
+
} catch (e) {
|
| 105 |
+
console.error('Failed to parse settings:', e);
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
if (loadedSettings) {
|
| 111 |
+
// Migrate old "notebooks" key to "agents"
|
| 112 |
+
if (loadedSettings.notebooks && !loadedSettings.agents) {
|
| 113 |
+
loadedSettings.agents = loadedSettings.notebooks;
|
| 114 |
+
delete loadedSettings.notebooks;
|
| 115 |
+
}
|
| 116 |
+
// Migrate if needed
|
| 117 |
+
if (!loadedSettings.settingsVersion || loadedSettings.settingsVersion < 2) {
|
| 118 |
+
loadedSettings = migrateSettings(loadedSettings);
|
| 119 |
+
// Save migrated settings
|
| 120 |
+
try {
|
| 121 |
+
await apiFetch('/api/settings', {
|
| 122 |
+
method: 'POST',
|
| 123 |
+
headers: { 'Content-Type': 'application/json' },
|
| 124 |
+
body: JSON.stringify(loadedSettings)
|
| 125 |
+
});
|
| 126 |
+
console.log('Migrated settings saved to file');
|
| 127 |
+
} catch (e) {
|
| 128 |
+
console.log('Could not save migrated settings to file');
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
settings = { ...settings, ...loadedSettings };
|
| 132 |
+
} else {
|
| 133 |
+
console.log('Using default settings:', settings);
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Generate unique ID for providers/models
|
| 138 |
+
function generateId(prefix) {
|
| 139 |
+
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// Render providers list in settings
|
| 143 |
+
function renderProvidersList() {
|
| 144 |
+
const container = document.getElementById('providers-list');
|
| 145 |
+
if (!container) return;
|
| 146 |
+
|
| 147 |
+
const providers = settings.providers || {};
|
| 148 |
+
let html = '';
|
| 149 |
+
|
| 150 |
+
Object.entries(providers).forEach(([id, provider]) => {
|
| 151 |
+
html += `
|
| 152 |
+
<div class="provider-item" data-provider-id="${id}">
|
| 153 |
+
<div class="provider-info">
|
| 154 |
+
<span class="provider-name">${escapeHtml(provider.name)}</span>
|
| 155 |
+
<span class="provider-endpoint">${escapeHtml(provider.endpoint)}</span>
|
| 156 |
+
</div>
|
| 157 |
+
<div class="provider-actions">
|
| 158 |
+
<button class="provider-edit-btn" onclick="editProvider('${id}')" title="Edit">β</button>
|
| 159 |
+
<button class="provider-delete-btn" onclick="deleteProvider('${id}')" title="Delete">Γ</button>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
`;
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
if (Object.keys(providers).length === 0) {
|
| 166 |
+
html = '<div class="empty-list">No providers configured. Add one to get started.</div>';
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
container.innerHTML = html;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// Render models list in settings
|
| 173 |
+
function renderModelsList() {
|
| 174 |
+
const container = document.getElementById('models-list');
|
| 175 |
+
if (!container) return;
|
| 176 |
+
|
| 177 |
+
const models = settings.models || {};
|
| 178 |
+
const providers = settings.providers || {};
|
| 179 |
+
let html = '';
|
| 180 |
+
|
| 181 |
+
Object.entries(models).forEach(([id, model]) => {
|
| 182 |
+
const provider = providers[model.providerId];
|
| 183 |
+
const providerName = provider ? provider.name : 'Unknown';
|
| 184 |
+
html += `
|
| 185 |
+
<div class="model-item" data-model-id="${id}">
|
| 186 |
+
<div class="model-info">
|
| 187 |
+
<span class="model-name">${escapeHtml(model.name)}</span>
|
| 188 |
+
<span class="model-details">${escapeHtml(model.modelId)} @ ${escapeHtml(providerName)}</span>
|
| 189 |
+
</div>
|
| 190 |
+
<div class="model-actions">
|
| 191 |
+
<button class="model-edit-btn" onclick="editModel('${id}')" title="Edit">β</button>
|
| 192 |
+
<button class="model-delete-btn" onclick="deleteModel('${id}')" title="Delete">Γ</button>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
`;
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
if (Object.keys(models).length === 0) {
|
| 199 |
+
html = '<div class="empty-list">No models configured. Add a provider first, then add models.</div>';
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
container.innerHTML = html;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// Populate model dropdowns for agent selection
|
| 206 |
+
function populateModelDropdowns() {
|
| 207 |
+
const models = settings.models || {};
|
| 208 |
+
const agents = settings.agents || {};
|
| 209 |
+
|
| 210 |
+
// Build dropdown IDs from registry + special dropdowns
|
| 211 |
+
const dropdownIds = [
|
| 212 |
+
...Object.keys(AGENT_REGISTRY).map(t => `setting-agent-${t}`),
|
| 213 |
+
'setting-research-sub-agent-model',
|
| 214 |
+
'setting-image-gen-model',
|
| 215 |
+
'setting-image-edit-model'
|
| 216 |
+
];
|
| 217 |
+
|
| 218 |
+
dropdownIds.forEach(dropdownId => {
|
| 219 |
+
const dropdown = document.getElementById(dropdownId);
|
| 220 |
+
if (!dropdown) return;
|
| 221 |
+
|
| 222 |
+
// Preserve current selection
|
| 223 |
+
const currentValue = dropdown.value;
|
| 224 |
+
|
| 225 |
+
// Clear and rebuild options
|
| 226 |
+
dropdown.innerHTML = '<option value="">-- Select Model --</option>';
|
| 227 |
+
|
| 228 |
+
Object.entries(models).forEach(([id, model]) => {
|
| 229 |
+
const option = document.createElement('option');
|
| 230 |
+
option.value = id;
|
| 231 |
+
option.textContent = `${model.name} (${model.modelId})`;
|
| 232 |
+
dropdown.appendChild(option);
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
// Restore selection
|
| 236 |
+
if (currentValue && models[currentValue]) {
|
| 237 |
+
dropdown.value = currentValue;
|
| 238 |
+
}
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
// Set values from settings (driven by registry)
|
| 242 |
+
for (const type of Object.keys(AGENT_REGISTRY)) {
|
| 243 |
+
const dropdown = document.getElementById(`setting-agent-${type}`);
|
| 244 |
+
if (dropdown) dropdown.value = agents[type] || '';
|
| 245 |
+
}
|
| 246 |
+
const subAgentDropdown = document.getElementById('setting-research-sub-agent-model');
|
| 247 |
+
if (subAgentDropdown) subAgentDropdown.value = settings.researchSubAgentModel || '';
|
| 248 |
+
const imageGenDropdown = document.getElementById('setting-image-gen-model');
|
| 249 |
+
if (imageGenDropdown) imageGenDropdown.value = settings.imageGenModel || '';
|
| 250 |
+
const imageEditDropdown = document.getElementById('setting-image-edit-model');
|
| 251 |
+
if (imageEditDropdown) imageEditDropdown.value = settings.imageEditModel || '';
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
// Show add/edit provider dialog
|
| 255 |
+
function showProviderDialog(providerId = null) {
|
| 256 |
+
const isEdit = !!providerId;
|
| 257 |
+
const provider = isEdit ? settings.providers[providerId] : { name: '', endpoint: '', token: '' };
|
| 258 |
+
|
| 259 |
+
const dialog = document.getElementById('provider-dialog');
|
| 260 |
+
const title = document.getElementById('provider-dialog-title');
|
| 261 |
+
const nameInput = document.getElementById('provider-name');
|
| 262 |
+
const endpointInput = document.getElementById('provider-endpoint');
|
| 263 |
+
const tokenInput = document.getElementById('provider-token');
|
| 264 |
+
|
| 265 |
+
title.textContent = isEdit ? 'Edit Provider' : 'Add Provider';
|
| 266 |
+
nameInput.value = provider.name;
|
| 267 |
+
endpointInput.value = provider.endpoint;
|
| 268 |
+
tokenInput.value = provider.token;
|
| 269 |
+
|
| 270 |
+
dialog.dataset.providerId = providerId || '';
|
| 271 |
+
dialog.classList.add('active');
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// Hide provider dialog
|
| 275 |
+
function hideProviderDialog() {
|
| 276 |
+
const dialog = document.getElementById('provider-dialog');
|
| 277 |
+
dialog.classList.remove('active');
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
// Save provider from dialog
|
| 281 |
+
function saveProviderFromDialog() {
|
| 282 |
+
const dialog = document.getElementById('provider-dialog');
|
| 283 |
+
const providerId = dialog.dataset.providerId || generateId('provider');
|
| 284 |
+
const name = document.getElementById('provider-name').value.trim();
|
| 285 |
+
const endpoint = document.getElementById('provider-endpoint').value.trim();
|
| 286 |
+
const token = document.getElementById('provider-token').value.trim();
|
| 287 |
+
|
| 288 |
+
if (!name || !endpoint) {
|
| 289 |
+
alert('Provider name and endpoint are required');
|
| 290 |
+
return;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
settings.providers[providerId] = { name, endpoint, token };
|
| 294 |
+
hideProviderDialog();
|
| 295 |
+
renderProvidersList();
|
| 296 |
+
populateModelDropdowns();
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
// Edit provider
|
| 300 |
+
function editProvider(providerId) {
|
| 301 |
+
showProviderDialog(providerId);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// Delete provider
|
| 305 |
+
function deleteProvider(providerId) {
|
| 306 |
+
// Check if any models use this provider
|
| 307 |
+
const modelsUsingProvider = Object.entries(settings.models || {})
|
| 308 |
+
.filter(([_, model]) => model.providerId === providerId);
|
| 309 |
+
|
| 310 |
+
if (modelsUsingProvider.length > 0) {
|
| 311 |
+
alert(`Cannot delete provider. ${modelsUsingProvider.length} model(s) are using it.`);
|
| 312 |
+
return;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
if (confirm('Delete this provider?')) {
|
| 316 |
+
delete settings.providers[providerId];
|
| 317 |
+
renderProvidersList();
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
// Show add/edit model dialog
|
| 322 |
+
function showModelDialog(modelId = null) {
|
| 323 |
+
const isEdit = !!modelId;
|
| 324 |
+
const model = isEdit ? settings.models[modelId] : { name: '', providerId: '', modelId: '', extraParams: null, multimodal: false };
|
| 325 |
+
|
| 326 |
+
const dialog = document.getElementById('model-dialog');
|
| 327 |
+
const title = document.getElementById('model-dialog-title');
|
| 328 |
+
const nameInput = document.getElementById('model-name');
|
| 329 |
+
const providerSelect = document.getElementById('model-provider');
|
| 330 |
+
const modelIdInput = document.getElementById('model-model-id');
|
| 331 |
+
const extraParamsInput = document.getElementById('model-extra-params');
|
| 332 |
+
const multimodalCheckbox = document.getElementById('model-multimodal');
|
| 333 |
+
|
| 334 |
+
title.textContent = isEdit ? 'Edit Model' : 'Add Model';
|
| 335 |
+
nameInput.value = model.name;
|
| 336 |
+
modelIdInput.value = model.modelId;
|
| 337 |
+
extraParamsInput.value = model.extraParams ? JSON.stringify(model.extraParams, null, 2) : '';
|
| 338 |
+
multimodalCheckbox.checked = !!model.multimodal;
|
| 339 |
+
|
| 340 |
+
// Populate provider dropdown
|
| 341 |
+
providerSelect.innerHTML = '<option value="">-- Select Provider --</option>';
|
| 342 |
+
Object.entries(settings.providers || {}).forEach(([id, provider]) => {
|
| 343 |
+
const option = document.createElement('option');
|
| 344 |
+
option.value = id;
|
| 345 |
+
option.textContent = provider.name;
|
| 346 |
+
if (id === model.providerId) option.selected = true;
|
| 347 |
+
providerSelect.appendChild(option);
|
| 348 |
+
});
|
| 349 |
+
|
| 350 |
+
dialog.dataset.modelId = modelId || '';
|
| 351 |
+
dialog.classList.add('active');
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
// Hide model dialog
|
| 355 |
+
function hideModelDialog() {
|
| 356 |
+
const dialog = document.getElementById('model-dialog');
|
| 357 |
+
dialog.classList.remove('active');
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
// Save model from dialog
|
| 361 |
+
function saveModelFromDialog() {
|
| 362 |
+
const dialog = document.getElementById('model-dialog');
|
| 363 |
+
const modelId = dialog.dataset.modelId || generateId('model');
|
| 364 |
+
const name = document.getElementById('model-name').value.trim();
|
| 365 |
+
const providerId = document.getElementById('model-provider').value;
|
| 366 |
+
const apiModelId = document.getElementById('model-model-id').value.trim();
|
| 367 |
+
const extraParamsStr = document.getElementById('model-extra-params').value.trim();
|
| 368 |
+
|
| 369 |
+
if (!name || !providerId || !apiModelId) {
|
| 370 |
+
alert('Name, provider, and model ID are required');
|
| 371 |
+
return;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
// Parse extra params if provided
|
| 375 |
+
let extraParams = null;
|
| 376 |
+
if (extraParamsStr) {
|
| 377 |
+
try {
|
| 378 |
+
extraParams = JSON.parse(extraParamsStr);
|
| 379 |
+
} catch (e) {
|
| 380 |
+
alert('Invalid JSON in extra parameters: ' + e.message);
|
| 381 |
+
return;
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
const multimodal = document.getElementById('model-multimodal').checked;
|
| 386 |
+
settings.models[modelId] = { name, providerId, modelId: apiModelId, extraParams, multimodal };
|
| 387 |
+
hideModelDialog();
|
| 388 |
+
renderModelsList();
|
| 389 |
+
populateModelDropdowns();
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
// Edit model
|
| 393 |
+
function editModel(modelId) {
|
| 394 |
+
showModelDialog(modelId);
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// Delete model
|
| 398 |
+
function deleteModel(modelId) {
|
| 399 |
+
// Check if any agents use this model
|
| 400 |
+
const agentsUsingModel = Object.entries(settings.agents || {})
|
| 401 |
+
.filter(([_, mid]) => mid === modelId);
|
| 402 |
+
|
| 403 |
+
if (agentsUsingModel.length > 0) {
|
| 404 |
+
const warning = `This model is used by: ${agentsUsingModel.map(([t]) => t).join(', ')}. Delete anyway?`;
|
| 405 |
+
if (!confirm(warning)) return;
|
| 406 |
+
|
| 407 |
+
// Clear the agent assignments
|
| 408 |
+
agentsUsingModel.forEach(([type]) => {
|
| 409 |
+
settings.agents[type] = '';
|
| 410 |
+
});
|
| 411 |
+
} else if (!confirm('Delete this model?')) {
|
| 412 |
+
return;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
delete settings.models[modelId];
|
| 416 |
+
renderModelsList();
|
| 417 |
+
populateModelDropdowns();
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
function openSettings() {
|
| 421 |
+
// Show settings file path
|
| 422 |
+
const pathEl = document.getElementById('settingsPath');
|
| 423 |
+
if (pathEl) pathEl.textContent = settings._settingsPath || '';
|
| 424 |
+
|
| 425 |
+
// Render providers and models lists
|
| 426 |
+
renderProvidersList();
|
| 427 |
+
renderModelsList();
|
| 428 |
+
populateModelDropdowns();
|
| 429 |
+
|
| 430 |
+
// Populate service keys
|
| 431 |
+
document.getElementById('setting-e2b-key').value = settings.e2bKey || '';
|
| 432 |
+
document.getElementById('setting-serper-key').value = settings.serperKey || '';
|
| 433 |
+
document.getElementById('setting-hf-token').value = settings.hfToken || '';
|
| 434 |
+
|
| 435 |
+
// Populate research settings
|
| 436 |
+
document.getElementById('setting-research-parallel-workers').value = settings.researchParallelWorkers || '';
|
| 437 |
+
document.getElementById('setting-research-max-websites').value = settings.researchMaxWebsites || '';
|
| 438 |
+
|
| 439 |
+
// Set theme color
|
| 440 |
+
const themeColor = settings.themeColor || 'forest';
|
| 441 |
+
document.getElementById('setting-theme-color').value = themeColor;
|
| 442 |
+
|
| 443 |
+
// Update selected theme in picker
|
| 444 |
+
const themePicker = document.getElementById('theme-color-picker');
|
| 445 |
+
if (themePicker) {
|
| 446 |
+
themePicker.querySelectorAll('.theme-option').forEach(opt => {
|
| 447 |
+
opt.classList.remove('selected');
|
| 448 |
+
if (opt.dataset.theme === themeColor) {
|
| 449 |
+
opt.classList.add('selected');
|
| 450 |
+
}
|
| 451 |
+
});
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
// Clear any status message
|
| 455 |
+
const status = document.getElementById('settingsStatus');
|
| 456 |
+
status.className = 'settings-status';
|
| 457 |
+
status.textContent = '';
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
async function saveSettings() {
|
| 461 |
+
// Get agent model selections from dropdowns (driven by registry)
|
| 462 |
+
const agentModels = {};
|
| 463 |
+
for (const type of Object.keys(AGENT_REGISTRY)) {
|
| 464 |
+
agentModels[type] = document.getElementById(`setting-agent-${type}`)?.value || '';
|
| 465 |
+
}
|
| 466 |
+
const researchSubAgentModel = document.getElementById('setting-research-sub-agent-model')?.value || '';
|
| 467 |
+
|
| 468 |
+
// Get other settings
|
| 469 |
+
const e2bKey = document.getElementById('setting-e2b-key').value.trim();
|
| 470 |
+
const serperKey = document.getElementById('setting-serper-key').value.trim();
|
| 471 |
+
const hfToken = document.getElementById('setting-hf-token').value.trim();
|
| 472 |
+
const imageGenModel = document.getElementById('setting-image-gen-model')?.value || '';
|
| 473 |
+
const imageEditModel = document.getElementById('setting-image-edit-model')?.value || '';
|
| 474 |
+
const researchParallelWorkers = document.getElementById('setting-research-parallel-workers').value.trim();
|
| 475 |
+
const researchMaxWebsites = document.getElementById('setting-research-max-websites').value.trim();
|
| 476 |
+
const themeColor = document.getElementById('setting-theme-color').value || 'forest';
|
| 477 |
+
|
| 478 |
+
// Validate: at least one provider and one model should exist
|
| 479 |
+
if (Object.keys(settings.providers || {}).length === 0) {
|
| 480 |
+
showSettingsStatus('Please add at least one provider', 'error');
|
| 481 |
+
return;
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
if (Object.keys(settings.models || {}).length === 0) {
|
| 485 |
+
showSettingsStatus('Please add at least one model', 'error');
|
| 486 |
+
return;
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
// Update settings
|
| 490 |
+
settings.agents = agentModels;
|
| 491 |
+
settings.e2bKey = e2bKey;
|
| 492 |
+
settings.serperKey = serperKey;
|
| 493 |
+
settings.hfToken = hfToken;
|
| 494 |
+
settings.imageGenModel = imageGenModel;
|
| 495 |
+
settings.imageEditModel = imageEditModel;
|
| 496 |
+
settings.researchSubAgentModel = researchSubAgentModel;
|
| 497 |
+
settings.researchParallelWorkers = researchParallelWorkers ? parseInt(researchParallelWorkers) : null;
|
| 498 |
+
settings.researchMaxWebsites = researchMaxWebsites ? parseInt(researchMaxWebsites) : null;
|
| 499 |
+
settings.themeColor = themeColor;
|
| 500 |
+
settings.settingsVersion = 2;
|
| 501 |
+
|
| 502 |
+
// Save to backend API (file-based) first
|
| 503 |
+
try {
|
| 504 |
+
const response = await apiFetch('/api/settings', {
|
| 505 |
+
method: 'POST',
|
| 506 |
+
headers: { 'Content-Type': 'application/json' },
|
| 507 |
+
body: JSON.stringify(settings)
|
| 508 |
+
});
|
| 509 |
+
if (response.ok) {
|
| 510 |
+
console.log('Settings saved to file:', settings);
|
| 511 |
+
} else {
|
| 512 |
+
console.error('Failed to save settings to file, falling back to localStorage');
|
| 513 |
+
localStorage.setItem('agentui_settings', JSON.stringify(settings));
|
| 514 |
+
}
|
| 515 |
+
} catch (e) {
|
| 516 |
+
console.error('Could not save settings to backend, falling back to localStorage:', e);
|
| 517 |
+
localStorage.setItem('agentui_settings', JSON.stringify(settings));
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
// Apply theme
|
| 521 |
+
applyTheme(themeColor);
|
| 522 |
+
|
| 523 |
+
// Show success message
|
| 524 |
+
showSettingsStatus('Settings saved successfully', 'success');
|
| 525 |
+
|
| 526 |
+
// Close settings panel and go back to command center after a short delay
|
| 527 |
+
setTimeout(() => {
|
| 528 |
+
const settingsPanel = document.getElementById('settingsPanel');
|
| 529 |
+
const settingsBtn = document.getElementById('settingsBtn');
|
| 530 |
+
const appContainer = document.querySelector('.app-container');
|
| 531 |
+
if (settingsPanel) settingsPanel.classList.remove('active');
|
| 532 |
+
if (settingsBtn) settingsBtn.classList.remove('active');
|
| 533 |
+
if (appContainer) appContainer.classList.remove('panel-open');
|
| 534 |
+
}, 1000);
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
function showSettingsStatus(message, type) {
|
| 538 |
+
const status = document.getElementById('settingsStatus');
|
| 539 |
+
status.textContent = message;
|
| 540 |
+
status.className = `settings-status ${type}`;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
// Theme colors mapping
|
| 544 |
+
// Default light surface colors shared by all light themes
|
| 545 |
+
const lightSurface = {
|
| 546 |
+
bgPrimary: '#ffffff',
|
| 547 |
+
bgSecondary: '#f5f5f5',
|
| 548 |
+
bgTertiary: '#fafafa',
|
| 549 |
+
bgInput: '#ffffff',
|
| 550 |
+
bgHover: '#f0f0f0',
|
| 551 |
+
bgCard: '#ffffff',
|
| 552 |
+
textPrimary: '#1a1a1a',
|
| 553 |
+
textSecondary: '#666666',
|
| 554 |
+
textMuted: '#999999',
|
| 555 |
+
borderPrimary: '#e0e0e0',
|
| 556 |
+
borderSubtle: '#f0f0f0'
|
| 557 |
+
};
|
| 558 |
+
|
| 559 |
+
const themeColors = {
|
| 560 |
+
forest: {
|
| 561 |
+
border: '#1b5e20', bg: '#e8f5e9', hoverBg: '#c8e6c9',
|
| 562 |
+
accent: '#1b5e20', accentRgb: '27, 94, 32',
|
| 563 |
+
...lightSurface
|
| 564 |
+
},
|
| 565 |
+
sapphire: {
|
| 566 |
+
border: '#0d47a1', bg: '#e3f2fd', hoverBg: '#bbdefb',
|
| 567 |
+
accent: '#0d47a1', accentRgb: '13, 71, 161',
|
| 568 |
+
...lightSurface
|
| 569 |
+
},
|
| 570 |
+
ocean: {
|
| 571 |
+
border: '#00796b', bg: '#e0f2f1', hoverBg: '#b2dfdb',
|
| 572 |
+
accent: '#004d40', accentRgb: '0, 77, 64',
|
| 573 |
+
...lightSurface
|
| 574 |
+
},
|
| 575 |
+
midnight: {
|
| 576 |
+
border: '#283593', bg: '#e8eaf6', hoverBg: '#c5cae9',
|
| 577 |
+
accent: '#1a237e', accentRgb: '26, 35, 126',
|
| 578 |
+
...lightSurface
|
| 579 |
+
},
|
| 580 |
+
steel: {
|
| 581 |
+
border: '#455a64', bg: '#eceff1', hoverBg: '#cfd8dc',
|
| 582 |
+
accent: '#263238', accentRgb: '38, 50, 56',
|
| 583 |
+
...lightSurface
|
| 584 |
+
},
|
| 585 |
+
depths: {
|
| 586 |
+
border: '#01579b', bg: '#e3f2fd', hoverBg: '#bbdefb',
|
| 587 |
+
accent: '#01579b', accentRgb: '1, 87, 155',
|
| 588 |
+
...lightSurface
|
| 589 |
+
},
|
| 590 |
+
ember: {
|
| 591 |
+
border: '#b71c1c', bg: '#fbe9e7', hoverBg: '#ffccbc',
|
| 592 |
+
accent: '#b71c1c', accentRgb: '183, 28, 28',
|
| 593 |
+
...lightSurface
|
| 594 |
+
},
|
| 595 |
+
noir: {
|
| 596 |
+
border: '#888888', bg: '#1a1a1a', hoverBg: '#2a2a2a',
|
| 597 |
+
accent: '#999999', accentRgb: '153, 153, 153',
|
| 598 |
+
bgPrimary: '#111111',
|
| 599 |
+
bgSecondary: '#1a1a1a',
|
| 600 |
+
bgTertiary: '#0d0d0d',
|
| 601 |
+
bgInput: '#0d0d0d',
|
| 602 |
+
bgHover: '#2a2a2a',
|
| 603 |
+
bgCard: '#1a1a1a',
|
| 604 |
+
textPrimary: '#e0e0e0',
|
| 605 |
+
textSecondary: '#999999',
|
| 606 |
+
textMuted: '#666666',
|
| 607 |
+
borderPrimary: '#333333',
|
| 608 |
+
borderSubtle: '#222222'
|
| 609 |
+
},
|
| 610 |
+
eclipse: {
|
| 611 |
+
border: '#5c9eff', bg: '#0d1520', hoverBg: '#162030',
|
| 612 |
+
accent: '#5c9eff', accentRgb: '92, 158, 255',
|
| 613 |
+
bgPrimary: '#0b1118',
|
| 614 |
+
bgSecondary: '#111a25',
|
| 615 |
+
bgTertiary: '#080e14',
|
| 616 |
+
bgInput: '#080e14',
|
| 617 |
+
bgHover: '#1a2840',
|
| 618 |
+
bgCard: '#111a25',
|
| 619 |
+
textPrimary: '#d0d8e8',
|
| 620 |
+
textSecondary: '#7088a8',
|
| 621 |
+
textMuted: '#4a6080',
|
| 622 |
+
borderPrimary: '#1e2e45',
|
| 623 |
+
borderSubtle: '#151f30'
|
| 624 |
+
},
|
| 625 |
+
terminal: {
|
| 626 |
+
border: '#00cc00', bg: '#0a1a0a', hoverBg: '#0d260d',
|
| 627 |
+
accent: '#00cc00', accentRgb: '0, 204, 0',
|
| 628 |
+
bgPrimary: '#0a0a0a',
|
| 629 |
+
bgSecondary: '#0d1a0d',
|
| 630 |
+
bgTertiary: '#050505',
|
| 631 |
+
bgInput: '#050505',
|
| 632 |
+
bgHover: '#1a3a1a',
|
| 633 |
+
bgCard: '#0d1a0d',
|
| 634 |
+
textPrimary: '#00cc00',
|
| 635 |
+
textSecondary: '#009900',
|
| 636 |
+
textMuted: '#007700',
|
| 637 |
+
borderPrimary: '#1a3a1a',
|
| 638 |
+
borderSubtle: '#0d1a0d'
|
| 639 |
+
}
|
| 640 |
+
};
|
| 641 |
+
|
| 642 |
+
function applyTheme(themeName) {
|
| 643 |
+
const theme = themeColors[themeName] || themeColors.forest;
|
| 644 |
+
const root = document.documentElement;
|
| 645 |
+
|
| 646 |
+
// Accent colors
|
| 647 |
+
root.style.setProperty('--theme-border', theme.border);
|
| 648 |
+
root.style.setProperty('--theme-bg', theme.bg);
|
| 649 |
+
root.style.setProperty('--theme-hover-bg', theme.hoverBg);
|
| 650 |
+
root.style.setProperty('--theme-accent', theme.accent);
|
| 651 |
+
root.style.setProperty('--theme-accent-rgb', theme.accentRgb);
|
| 652 |
+
|
| 653 |
+
// Surface colors
|
| 654 |
+
root.style.setProperty('--bg-primary', theme.bgPrimary);
|
| 655 |
+
root.style.setProperty('--bg-secondary', theme.bgSecondary);
|
| 656 |
+
root.style.setProperty('--bg-tertiary', theme.bgTertiary);
|
| 657 |
+
root.style.setProperty('--bg-input', theme.bgInput);
|
| 658 |
+
root.style.setProperty('--bg-hover', theme.bgHover);
|
| 659 |
+
root.style.setProperty('--bg-card', theme.bgCard);
|
| 660 |
+
root.style.setProperty('--text-primary', theme.textPrimary);
|
| 661 |
+
root.style.setProperty('--text-secondary', theme.textSecondary);
|
| 662 |
+
root.style.setProperty('--text-muted', theme.textMuted);
|
| 663 |
+
root.style.setProperty('--border-primary', theme.borderPrimary);
|
| 664 |
+
root.style.setProperty('--border-subtle', theme.borderSubtle);
|
| 665 |
+
|
| 666 |
+
// Data attribute for any remaining theme-specific overrides
|
| 667 |
+
document.body.setAttribute('data-theme', themeName);
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
// Export settings for use in API calls
|
| 671 |
+
function getSettings() {
|
| 672 |
+
return settings;
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
// Resolve model configuration for an agent type
|
| 676 |
+
// Returns { endpoint, token, model, extraParams } or null if not configured
|
| 677 |
+
function resolveModelConfig(agentType) {
|
| 678 |
+
const modelId = settings.agents?.[agentType];
|
| 679 |
+
if (!modelId) return null;
|
| 680 |
+
|
| 681 |
+
const model = settings.models?.[modelId];
|
| 682 |
+
if (!model) return null;
|
| 683 |
+
|
| 684 |
+
const provider = settings.providers?.[model.providerId];
|
| 685 |
+
if (!provider) return null;
|
| 686 |
+
|
| 687 |
+
return {
|
| 688 |
+
endpoint: provider.endpoint,
|
| 689 |
+
token: provider.token,
|
| 690 |
+
model: model.modelId,
|
| 691 |
+
extraParams: model.extraParams || null,
|
| 692 |
+
multimodal: !!model.multimodal
|
| 693 |
+
};
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
// Get first available model config as fallback
|
| 697 |
+
function getDefaultModelConfig() {
|
| 698 |
+
const modelIds = Object.keys(settings.models || {});
|
| 699 |
+
if (modelIds.length === 0) return null;
|
| 700 |
+
|
| 701 |
+
const modelId = modelIds[0];
|
| 702 |
+
const model = settings.models[modelId];
|
| 703 |
+
const provider = settings.providers?.[model.providerId];
|
| 704 |
+
if (!provider) return null;
|
| 705 |
+
|
| 706 |
+
return {
|
| 707 |
+
endpoint: provider.endpoint,
|
| 708 |
+
token: provider.token,
|
| 709 |
+
model: model.modelId,
|
| 710 |
+
extraParams: model.extraParams || null,
|
| 711 |
+
multimodal: !!model.multimodal
|
| 712 |
+
};
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
// Build frontend context for API requests
|
| 716 |
+
function getFrontendContext() {
|
| 717 |
+
const currentThemeName = settings.themeColor || 'forest';
|
| 718 |
+
const theme = themeColors[currentThemeName];
|
| 719 |
+
|
| 720 |
+
return {
|
| 721 |
+
theme: theme ? {
|
| 722 |
+
name: currentThemeName,
|
| 723 |
+
accent: theme.accent,
|
| 724 |
+
bg: theme.bg,
|
| 725 |
+
border: theme.border,
|
| 726 |
+
bgPrimary: theme.bgPrimary,
|
| 727 |
+
bgSecondary: theme.bgSecondary,
|
| 728 |
+
textPrimary: theme.textPrimary,
|
| 729 |
+
textSecondary: theme.textSecondary
|
| 730 |
+
} : null,
|
| 731 |
+
open_agents: getOpenAgentTypes()
|
| 732 |
+
};
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
// Get list of open agent types
|
| 736 |
+
function getOpenAgentTypes() {
|
| 737 |
+
const tabs = document.querySelectorAll('.tab[data-tab-id]');
|
| 738 |
+
const types = [];
|
| 739 |
+
tabs.forEach(tab => {
|
| 740 |
+
const tabId = tab.dataset.tabId;
|
| 741 |
+
if (tabId === '0') {
|
| 742 |
+
types.push('command');
|
| 743 |
+
} else {
|
| 744 |
+
const content = document.querySelector(`[data-content-id="${tabId}"]`);
|
| 745 |
+
if (content) {
|
| 746 |
+
const chatContainer = content.querySelector('.chat-container');
|
| 747 |
+
if (chatContainer && chatContainer.dataset.agentType) {
|
| 748 |
+
types.push(chatContainer.dataset.agentType);
|
| 749 |
+
}
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
});
|
| 753 |
+
return types;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
// Sandbox management for code agents
|
| 757 |
+
async function startSandbox(tabId) {
|
| 758 |
+
const currentSettings = getSettings();
|
| 759 |
+
const backendEndpoint = '/api';
|
| 760 |
+
|
| 761 |
+
if (!currentSettings.e2bKey) {
|
| 762 |
+
console.log('No E2B key configured, skipping sandbox start');
|
| 763 |
+
return;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
// Add a status message to the agent
|
| 767 |
+
const uniqueId = `code-${tabId}`;
|
| 768 |
+
const chatContainer = document.getElementById(`messages-${uniqueId}`);
|
| 769 |
+
if (chatContainer) {
|
| 770 |
+
const statusMsg = document.createElement('div');
|
| 771 |
+
statusMsg.className = 'system-message';
|
| 772 |
+
statusMsg.innerHTML = '<em>βοΈ Starting sandbox...</em>';
|
| 773 |
+
chatContainer.appendChild(statusMsg);
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
try {
|
| 777 |
+
const response = await apiFetch(`${backendEndpoint}/sandbox/start`, {
|
| 778 |
+
method: 'POST',
|
| 779 |
+
headers: { 'Content-Type': 'application/json' },
|
| 780 |
+
body: JSON.stringify({
|
| 781 |
+
session_id: tabId.toString(),
|
| 782 |
+
e2b_key: currentSettings.e2bKey
|
| 783 |
+
})
|
| 784 |
+
});
|
| 785 |
+
|
| 786 |
+
const result = await response.json();
|
| 787 |
+
|
| 788 |
+
// Update status message
|
| 789 |
+
if (chatContainer) {
|
| 790 |
+
const statusMsg = chatContainer.querySelector('.system-message');
|
| 791 |
+
if (statusMsg) {
|
| 792 |
+
if (result.success) {
|
| 793 |
+
// Sandbox is ready - hide the message
|
| 794 |
+
statusMsg.remove();
|
| 795 |
+
} else {
|
| 796 |
+
statusMsg.innerHTML = `<em>β Sandbox error: ${result.error}</em>`;
|
| 797 |
+
statusMsg.style.color = '#c62828';
|
| 798 |
+
}
|
| 799 |
+
}
|
| 800 |
+
}
|
| 801 |
+
} catch (error) {
|
| 802 |
+
console.error('Failed to start sandbox:', error);
|
| 803 |
+
if (chatContainer) {
|
| 804 |
+
const statusMsg = chatContainer.querySelector('.system-message');
|
| 805 |
+
if (statusMsg) {
|
| 806 |
+
statusMsg.innerHTML = `<em>β Failed to start sandbox: ${error.message}</em>`;
|
| 807 |
+
statusMsg.style.color = '#c62828';
|
| 808 |
+
}
|
| 809 |
+
}
|
| 810 |
+
}
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
async function stopSandbox(tabId) {
|
| 814 |
+
const backendEndpoint = '/api';
|
| 815 |
+
|
| 816 |
+
try {
|
| 817 |
+
await apiFetch(`${backendEndpoint}/sandbox/stop`, {
|
| 818 |
+
method: 'POST',
|
| 819 |
+
headers: { 'Content-Type': 'application/json' },
|
| 820 |
+
body: JSON.stringify({
|
| 821 |
+
session_id: tabId.toString()
|
| 822 |
+
})
|
| 823 |
+
});
|
| 824 |
+
} catch (error) {
|
| 825 |
+
console.error('Failed to stop sandbox:', error);
|
| 826 |
+
}
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
// Image modal for click-to-zoom
|
| 830 |
+
function openImageModal(src) {
|
| 831 |
+
// Create modal if it doesn't exist
|
| 832 |
+
let modal = document.getElementById('imageModal');
|
| 833 |
+
if (!modal) {
|
| 834 |
+
modal = document.createElement('div');
|
| 835 |
+
modal.id = 'imageModal';
|
| 836 |
+
modal.style.cssText = `
|
| 837 |
+
display: none;
|
| 838 |
+
position: fixed;
|
| 839 |
+
z-index: 10000;
|
| 840 |
+
left: 0;
|
| 841 |
+
top: 0;
|
| 842 |
+
width: 100%;
|
| 843 |
+
height: 100%;
|
| 844 |
+
background-color: rgba(0, 0, 0, 0.9);
|
| 845 |
+
cursor: pointer;
|
| 846 |
+
`;
|
| 847 |
+
modal.onclick = function() {
|
| 848 |
+
modal.style.display = 'none';
|
| 849 |
+
};
|
| 850 |
+
|
| 851 |
+
const img = document.createElement('img');
|
| 852 |
+
img.id = 'imageModalContent';
|
| 853 |
+
img.style.cssText = `
|
| 854 |
+
margin: auto;
|
| 855 |
+
display: block;
|
| 856 |
+
max-width: 95%;
|
| 857 |
+
max-height: 95%;
|
| 858 |
+
position: absolute;
|
| 859 |
+
top: 50%;
|
| 860 |
+
left: 50%;
|
| 861 |
+
transform: translate(-50%, -50%);
|
| 862 |
+
`;
|
| 863 |
+
modal.appendChild(img);
|
| 864 |
+
document.body.appendChild(modal);
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
// Show modal with image
|
| 868 |
+
const modalImg = document.getElementById('imageModalContent');
|
| 869 |
+
modalImg.src = src;
|
| 870 |
+
modal.style.display = 'block';
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
// ============= DEBUG PANEL =============
|
| 874 |
+
|
| 875 |
+
const debugPanel = document.getElementById('debugPanel');
|
| 876 |
+
const debugBtn = document.getElementById('debugBtn');
|
| 877 |
+
const debugClose = document.getElementById('debugClose');
|
| 878 |
+
const debugContent = document.getElementById('debugContent');
|
| 879 |
+
|
| 880 |
+
// Toggle debug panel
|
| 881 |
+
if (debugBtn) {
|
| 882 |
+
debugBtn.addEventListener('click', () => {
|
| 883 |
+
const isOpening = !debugPanel.classList.contains('active');
|
| 884 |
+
|
| 885 |
+
// Close all panels first, then toggle debug
|
| 886 |
+
closeAllPanels();
|
| 887 |
+
|
| 888 |
+
if (isOpening) {
|
| 889 |
+
debugPanel.classList.add('active');
|
| 890 |
+
debugBtn.classList.add('active');
|
| 891 |
+
appContainer.classList.add('panel-open');
|
| 892 |
+
loadDebugMessages();
|
| 893 |
+
}
|
| 894 |
+
});
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
// Close debug panel
|
| 898 |
+
if (debugClose) {
|
| 899 |
+
debugClose.addEventListener('click', () => {
|
| 900 |
+
debugPanel.classList.remove('active');
|
| 901 |
+
debugBtn.classList.remove('active');
|
| 902 |
+
appContainer.classList.remove('panel-open');
|
| 903 |
+
});
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
|
| 907 |
+
// Load debug messages from backend
|
| 908 |
+
function formatDebugJson(obj) {
|
| 909 |
+
/**
|
| 910 |
+
* Format an object as HTML-escaped JSON, replacing base64 image data
|
| 911 |
+
* with clickable placeholders that show a thumbnail on hover.
|
| 912 |
+
*/
|
| 913 |
+
// Collect base64 images and replace with placeholders before escaping
|
| 914 |
+
const images = [];
|
| 915 |
+
const json = JSON.stringify(obj, null, 2);
|
| 916 |
+
const placeholder = json.replace(
|
| 917 |
+
/"(data:image\/[^;]+;base64,)([A-Za-z0-9+/=\n]{200,})"/g,
|
| 918 |
+
(match, prefix, b64) => {
|
| 919 |
+
const idx = images.length;
|
| 920 |
+
const sizeKB = (b64.length * 0.75 / 1024).toFixed(1);
|
| 921 |
+
images.push(prefix + b64);
|
| 922 |
+
return `"__DEBUG_IMG_${idx}_${sizeKB}KB__"`;
|
| 923 |
+
}
|
| 924 |
+
);
|
| 925 |
+
// Now HTML-escape the JSON (placeholders are safe ASCII)
|
| 926 |
+
let html = escapeHtml(placeholder);
|
| 927 |
+
// Replace placeholders with hoverable image thumbnails
|
| 928 |
+
html = html.replace(/__DEBUG_IMG_(\d+)_([\d.]+KB)__/g, (match, idx, size) => {
|
| 929 |
+
const src = images[parseInt(idx)];
|
| 930 |
+
return `<span class="debug-image-placeholder" onmouseenter="this.querySelector('.debug-image-tooltip').style.display='block'" onmouseleave="this.querySelector('.debug-image-tooltip').style.display='none'">[image ${size}]<span class="debug-image-tooltip"><img src="${src}"></span></span>`;
|
| 931 |
+
});
|
| 932 |
+
return html;
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
function loadDebugMessages() {
|
| 936 |
+
const calls = debugHistory[activeTabId] || [];
|
| 937 |
+
|
| 938 |
+
if (calls.length === 0) {
|
| 939 |
+
debugContent.innerHTML = '<div style="padding: 10px; color: var(--text-secondary);">No LLM calls recorded yet.<br><br>Send a message in this tab to see the call history here.</div>';
|
| 940 |
+
return;
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
debugContent.innerHTML = calls.map((call, i) => {
|
| 944 |
+
const isLast = i === calls.length - 1;
|
| 945 |
+
const arrow = isLast ? 'βΌ' : 'βΆ';
|
| 946 |
+
const display = isLast ? 'block' : 'none';
|
| 947 |
+
const msgCount = call.input ? call.input.length : 0;
|
| 948 |
+
|
| 949 |
+
const inputHtml = call.input ? formatDebugJson(call.input) : '<em>No input</em>';
|
| 950 |
+
|
| 951 |
+
let outputHtml;
|
| 952 |
+
if (call.error) {
|
| 953 |
+
outputHtml = `<span style="color: #d32f2f;">${escapeHtml(call.error)}</span>`;
|
| 954 |
+
} else if (call.output) {
|
| 955 |
+
outputHtml = formatDebugJson(call.output);
|
| 956 |
+
} else {
|
| 957 |
+
outputHtml = '<em>Pending...</em>';
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
return `<div class="debug-call-item${isLast ? ' expanded' : ''}" id="callitem-${i}"><div class="debug-call-header" onclick="toggleDebugCall(${i})"><span class="debug-call-arrow" id="arrow-${i}">${arrow}</span><span class="debug-call-title">Call #${i + 1}</span><span class="debug-call-time">${call.timestamp}</span></div><div class="debug-call-content" id="call-${i}" style="display: ${display};"><div class="debug-section-label">INPUT (${msgCount} messages)</div><pre>${inputHtml}</pre><div class="debug-section-label">OUTPUT</div><pre>${outputHtml}</pre></div></div>`;
|
| 961 |
+
}).join('');
|
| 962 |
+
}
|
| 963 |
+
|
| 964 |
+
// Toggle debug call expansion
|
| 965 |
+
window.toggleDebugCall = function(index) {
|
| 966 |
+
const content = document.getElementById(`call-${index}`);
|
| 967 |
+
const arrow = document.getElementById(`arrow-${index}`);
|
| 968 |
+
const item = document.getElementById(`callitem-${index}`);
|
| 969 |
+
if (content.style.display === 'none') {
|
| 970 |
+
content.style.display = 'block';
|
| 971 |
+
arrow.textContent = 'βΌ';
|
| 972 |
+
item.classList.add('expanded');
|
| 973 |
+
} else {
|
| 974 |
+
content.style.display = 'none';
|
| 975 |
+
arrow.textContent = 'βΆ';
|
| 976 |
+
item.classList.remove('expanded');
|
| 977 |
+
}
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
// ============= SETTINGS PANEL =============
|
| 981 |
+
|
| 982 |
+
const settingsPanel = document.getElementById('settingsPanel');
|
| 983 |
+
const settingsPanelBody = document.getElementById('settingsPanelBody');
|
| 984 |
+
const settingsPanelClose = document.getElementById('settingsPanelClose');
|
| 985 |
+
const settingsBtn = document.getElementById('settingsBtn');
|
| 986 |
+
const appContainer = document.querySelector('.app-container');
|
| 987 |
+
|
| 988 |
+
|
| 989 |
+
// Open settings panel when SETTINGS button is clicked
|
| 990 |
+
if (settingsBtn) {
|
| 991 |
+
settingsBtn.addEventListener('click', () => {
|
| 992 |
+
closeAllPanels();
|
| 993 |
+
openSettings();
|
| 994 |
+
settingsPanel.classList.add('active');
|
| 995 |
+
settingsBtn.classList.add('active');
|
| 996 |
+
appContainer.classList.add('panel-open');
|
| 997 |
+
});
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
// Close settings panel
|
| 1001 |
+
if (settingsPanelClose) {
|
| 1002 |
+
settingsPanelClose.addEventListener('click', () => {
|
| 1003 |
+
settingsPanel.classList.remove('active');
|
| 1004 |
+
settingsBtn.classList.remove('active');
|
| 1005 |
+
appContainer.classList.remove('panel-open');
|
| 1006 |
+
});
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
|
| 1010 |
+
// ============= FILES PANEL =============
|
| 1011 |
+
|
| 1012 |
+
const filesPanel = document.getElementById('filesPanel');
|
| 1013 |
+
const filesPanelClose = document.getElementById('filesPanelClose');
|
| 1014 |
+
const filesBtn = document.getElementById('filesBtn');
|
| 1015 |
+
const fileTree = document.getElementById('fileTree');
|
| 1016 |
+
const showHiddenFiles = document.getElementById('showHiddenFiles');
|
| 1017 |
+
const filesRefresh = document.getElementById('filesRefresh');
|
| 1018 |
+
const filesUpload = document.getElementById('filesUpload');
|
| 1019 |
+
|
| 1020 |
+
// Track expanded folder paths to preserve state on refresh
|
| 1021 |
+
let expandedPaths = new Set();
|
| 1022 |
+
let filesRoot = '';
|
| 1023 |
+
|
| 1024 |
+
// Load file tree from API
|
| 1025 |
+
async function loadFileTree() {
|
| 1026 |
+
const showHidden = showHiddenFiles?.checked || false;
|
| 1027 |
+
try {
|
| 1028 |
+
const response = await apiFetch(`/api/files?show_hidden=${showHidden}`);
|
| 1029 |
+
if (response.ok) {
|
| 1030 |
+
const data = await response.json();
|
| 1031 |
+
filesRoot = data.root;
|
| 1032 |
+
renderFileTree(data.tree, fileTree, data.root);
|
| 1033 |
+
} else {
|
| 1034 |
+
fileTree.innerHTML = '<div class="files-loading">Failed to load files</div>';
|
| 1035 |
+
}
|
| 1036 |
+
} catch (e) {
|
| 1037 |
+
console.error('Failed to load file tree:', e);
|
| 1038 |
+
fileTree.innerHTML = '<div class="files-loading">Failed to load files</div>';
|
| 1039 |
+
}
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
// Render file tree recursively
|
| 1043 |
+
function renderFileTree(tree, container, rootPath) {
|
| 1044 |
+
container.innerHTML = '';
|
| 1045 |
+
const rootWrapper = document.createElement('div');
|
| 1046 |
+
rootWrapper.className = 'file-tree-root';
|
| 1047 |
+
|
| 1048 |
+
// Add header with folder name
|
| 1049 |
+
const header = document.createElement('div');
|
| 1050 |
+
header.className = 'file-tree-header';
|
| 1051 |
+
const folderName = rootPath.split('/').pop() || rootPath;
|
| 1052 |
+
header.textContent = './' + folderName;
|
| 1053 |
+
rootWrapper.appendChild(header);
|
| 1054 |
+
|
| 1055 |
+
// Container with vertical line
|
| 1056 |
+
const treeContainer = document.createElement('div');
|
| 1057 |
+
treeContainer.className = 'file-tree-container';
|
| 1058 |
+
renderTreeItems(tree, treeContainer);
|
| 1059 |
+
rootWrapper.appendChild(treeContainer);
|
| 1060 |
+
|
| 1061 |
+
container.appendChild(rootWrapper);
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
function renderTreeItems(tree, container) {
|
| 1065 |
+
const len = tree.length;
|
| 1066 |
+
for (let i = 0; i < len; i++) {
|
| 1067 |
+
const item = tree[i];
|
| 1068 |
+
const isLast = (i === len - 1);
|
| 1069 |
+
|
| 1070 |
+
const itemEl = document.createElement('div');
|
| 1071 |
+
itemEl.className = `file-tree-item ${item.type}`;
|
| 1072 |
+
if (isLast) itemEl.classList.add('last');
|
| 1073 |
+
itemEl.dataset.path = item.path;
|
| 1074 |
+
|
| 1075 |
+
// Check if this folder was previously expanded
|
| 1076 |
+
const wasExpanded = expandedPaths.has(item.path);
|
| 1077 |
+
|
| 1078 |
+
// Create the clickable line element
|
| 1079 |
+
const lineEl = document.createElement('div');
|
| 1080 |
+
lineEl.className = 'file-tree-line';
|
| 1081 |
+
lineEl.draggable = true;
|
| 1082 |
+
|
| 1083 |
+
// Only folders get an icon (arrow), files get empty icon
|
| 1084 |
+
const icon = item.type === 'folder' ? (wasExpanded ? 'βΌ' : 'βΆ') : '';
|
| 1085 |
+
const actionBtn = item.type === 'file'
|
| 1086 |
+
? '<button class="file-tree-action-btn file-download-btn" title="Download">β</button>'
|
| 1087 |
+
: '<button class="file-tree-action-btn file-upload-btn" title="Upload file here">+</button>';
|
| 1088 |
+
lineEl.innerHTML = `
|
| 1089 |
+
<span class="file-tree-icon">${icon}</span>
|
| 1090 |
+
<span class="file-tree-name">${item.name}</span>
|
| 1091 |
+
<span class="file-tree-actions">${actionBtn}</span>
|
| 1092 |
+
`;
|
| 1093 |
+
itemEl.appendChild(lineEl);
|
| 1094 |
+
|
| 1095 |
+
// Download button (files)
|
| 1096 |
+
const downloadBtn = lineEl.querySelector('.file-download-btn');
|
| 1097 |
+
if (downloadBtn) {
|
| 1098 |
+
downloadBtn.addEventListener('click', (e) => {
|
| 1099 |
+
e.stopPropagation();
|
| 1100 |
+
window.open(`/api/files/download?path=${encodeURIComponent(item.path)}${SESSION_ID ? '&session_id=' + encodeURIComponent(SESSION_ID) : ''}`, '_blank');
|
| 1101 |
+
});
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
// Upload button (folders)
|
| 1105 |
+
const uploadBtn = lineEl.querySelector('.file-upload-btn');
|
| 1106 |
+
if (uploadBtn) {
|
| 1107 |
+
uploadBtn.addEventListener('click', (e) => {
|
| 1108 |
+
e.stopPropagation();
|
| 1109 |
+
const input = document.createElement('input');
|
| 1110 |
+
input.type = 'file';
|
| 1111 |
+
input.addEventListener('change', async () => {
|
| 1112 |
+
if (!input.files.length) return;
|
| 1113 |
+
const formData = new FormData();
|
| 1114 |
+
formData.append('file', input.files[0]);
|
| 1115 |
+
try {
|
| 1116 |
+
await apiFetch(`/api/files/upload?folder=${encodeURIComponent(item.path)}`, {
|
| 1117 |
+
method: 'POST',
|
| 1118 |
+
body: formData
|
| 1119 |
+
});
|
| 1120 |
+
loadFileTree();
|
| 1121 |
+
} catch (err) {
|
| 1122 |
+
console.error('Upload failed:', err);
|
| 1123 |
+
}
|
| 1124 |
+
});
|
| 1125 |
+
input.click();
|
| 1126 |
+
});
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
container.appendChild(itemEl);
|
| 1130 |
+
|
| 1131 |
+
// Handle folder expansion
|
| 1132 |
+
if (item.type === 'folder' && item.children && item.children.length > 0) {
|
| 1133 |
+
const childrenContainer = document.createElement('div');
|
| 1134 |
+
childrenContainer.className = 'file-tree-children';
|
| 1135 |
+
if (wasExpanded) {
|
| 1136 |
+
childrenContainer.classList.add('expanded');
|
| 1137 |
+
itemEl.classList.add('expanded');
|
| 1138 |
+
}
|
| 1139 |
+
renderTreeItems(item.children, childrenContainer);
|
| 1140 |
+
itemEl.appendChild(childrenContainer);
|
| 1141 |
+
|
| 1142 |
+
// Use click delay to distinguish single vs double click
|
| 1143 |
+
let clickTimer = null;
|
| 1144 |
+
lineEl.addEventListener('click', (e) => {
|
| 1145 |
+
e.stopPropagation();
|
| 1146 |
+
if (clickTimer) {
|
| 1147 |
+
// Double click detected - clear timer and expand/collapse
|
| 1148 |
+
clearTimeout(clickTimer);
|
| 1149 |
+
clickTimer = null;
|
| 1150 |
+
const isExpanded = itemEl.classList.toggle('expanded');
|
| 1151 |
+
childrenContainer.classList.toggle('expanded');
|
| 1152 |
+
const iconEl = lineEl.querySelector('.file-tree-icon');
|
| 1153 |
+
if (iconEl) iconEl.textContent = isExpanded ? 'βΌ' : 'βΆ';
|
| 1154 |
+
if (isExpanded) {
|
| 1155 |
+
expandedPaths.add(item.path);
|
| 1156 |
+
} else {
|
| 1157 |
+
expandedPaths.delete(item.path);
|
| 1158 |
+
}
|
| 1159 |
+
} else {
|
| 1160 |
+
// Single click - wait to see if it's a double click
|
| 1161 |
+
clickTimer = setTimeout(() => {
|
| 1162 |
+
clickTimer = null;
|
| 1163 |
+
insertPathIntoInput('./' + item.path);
|
| 1164 |
+
showClickFeedback(lineEl);
|
| 1165 |
+
}, 250);
|
| 1166 |
+
}
|
| 1167 |
+
});
|
| 1168 |
+
} else if (item.type === 'file') {
|
| 1169 |
+
// Single click on file inserts path
|
| 1170 |
+
lineEl.addEventListener('click', (e) => {
|
| 1171 |
+
e.stopPropagation();
|
| 1172 |
+
insertPathIntoInput('./' + item.path);
|
| 1173 |
+
showClickFeedback(lineEl);
|
| 1174 |
+
});
|
| 1175 |
+
}
|
| 1176 |
+
|
| 1177 |
+
// Drag start handler for future drag-and-drop
|
| 1178 |
+
lineEl.addEventListener('dragstart', (e) => {
|
| 1179 |
+
e.dataTransfer.setData('text/plain', './' + item.path);
|
| 1180 |
+
e.dataTransfer.setData('application/x-file-path', './' + item.path);
|
| 1181 |
+
e.dataTransfer.effectAllowed = 'copy';
|
| 1182 |
+
});
|
| 1183 |
+
}
|
| 1184 |
+
}
|
| 1185 |
+
|
| 1186 |
+
// Helper to insert path into active input
|
| 1187 |
+
function insertPathIntoInput(path) {
|
| 1188 |
+
const inputId = activeTabId === 0 ? 'input-command' : `input-${activeTabId}`;
|
| 1189 |
+
const inputEl = document.getElementById(inputId);
|
| 1190 |
+
if (inputEl) {
|
| 1191 |
+
const start = inputEl.selectionStart;
|
| 1192 |
+
const end = inputEl.selectionEnd;
|
| 1193 |
+
const text = inputEl.value;
|
| 1194 |
+
// Wrap path in backticks and add trailing space
|
| 1195 |
+
const formattedPath = '`' + path + '` ';
|
| 1196 |
+
inputEl.value = text.substring(0, start) + formattedPath + text.substring(end);
|
| 1197 |
+
inputEl.focus();
|
| 1198 |
+
inputEl.selectionStart = inputEl.selectionEnd = start + formattedPath.length;
|
| 1199 |
+
}
|
| 1200 |
+
}
|
| 1201 |
+
|
| 1202 |
+
// Linkify inline code elements that match existing file paths
|
| 1203 |
+
async function linkifyFilePaths(container) {
|
| 1204 |
+
// Find all inline <code> elements (not inside <pre>)
|
| 1205 |
+
const codeEls = [...container.querySelectorAll('code')].filter(c => !c.closest('pre'));
|
| 1206 |
+
if (codeEls.length === 0) return;
|
| 1207 |
+
|
| 1208 |
+
// Collect candidate paths (must look like a file path)
|
| 1209 |
+
const candidates = new Map(); // normalized path -> code element(s)
|
| 1210 |
+
for (const code of codeEls) {
|
| 1211 |
+
const text = code.textContent.trim();
|
| 1212 |
+
if (!text || text.includes(' ') || text.length > 200) continue;
|
| 1213 |
+
// Must contain a dot (extension) or slash (directory)
|
| 1214 |
+
if (!text.includes('.') && !text.includes('/')) continue;
|
| 1215 |
+
const normalized = text.replace(/^\.\//, '');
|
| 1216 |
+
if (!candidates.has(normalized)) candidates.set(normalized, []);
|
| 1217 |
+
candidates.get(normalized).push(code);
|
| 1218 |
+
}
|
| 1219 |
+
if (candidates.size === 0) return;
|
| 1220 |
+
|
| 1221 |
+
// Check which paths exist on the server
|
| 1222 |
+
try {
|
| 1223 |
+
const resp = await apiFetch('/api/files/check', {
|
| 1224 |
+
method: 'POST',
|
| 1225 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1226 |
+
body: JSON.stringify({ paths: [...candidates.keys()] })
|
| 1227 |
+
});
|
| 1228 |
+
if (!resp.ok) return;
|
| 1229 |
+
const { existing } = await resp.json();
|
| 1230 |
+
|
| 1231 |
+
for (const path of existing) {
|
| 1232 |
+
for (const code of candidates.get(path) || []) {
|
| 1233 |
+
if (code.closest('.file-path-link')) continue; // already linked
|
| 1234 |
+
const link = document.createElement('a');
|
| 1235 |
+
link.className = 'file-path-link';
|
| 1236 |
+
link.href = '#';
|
| 1237 |
+
link.title = 'Open in file explorer';
|
| 1238 |
+
link.addEventListener('click', (e) => {
|
| 1239 |
+
e.preventDefault();
|
| 1240 |
+
navigateToFileInExplorer(path);
|
| 1241 |
+
});
|
| 1242 |
+
code.parentNode.insertBefore(link, code);
|
| 1243 |
+
link.appendChild(code);
|
| 1244 |
+
}
|
| 1245 |
+
}
|
| 1246 |
+
} catch (e) {
|
| 1247 |
+
// Silently fail β linkification is a nice-to-have
|
| 1248 |
+
}
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
// Helper to show click feedback
|
| 1252 |
+
function showClickFeedback(el) {
|
| 1253 |
+
const originalColor = el.style.color;
|
| 1254 |
+
el.style.color = 'var(--theme-accent)';
|
| 1255 |
+
setTimeout(() => {
|
| 1256 |
+
el.style.color = originalColor;
|
| 1257 |
+
}, 300);
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
// Navigate to a file in the file explorer and highlight it
|
| 1261 |
+
function navigateToFileInExplorer(path) {
|
| 1262 |
+
let relPath = path.replace(/^\.\//, '');
|
| 1263 |
+
|
| 1264 |
+
// Open files panel if not already open
|
| 1265 |
+
if (!filesPanel.classList.contains('active')) {
|
| 1266 |
+
filesBtn.click();
|
| 1267 |
+
}
|
| 1268 |
+
|
| 1269 |
+
// Wait for tree to render, then expand parents and highlight
|
| 1270 |
+
setTimeout(() => {
|
| 1271 |
+
const segments = relPath.split('/');
|
| 1272 |
+
let currentPath = '';
|
| 1273 |
+
for (let i = 0; i < segments.length - 1; i++) {
|
| 1274 |
+
currentPath += (i > 0 ? '/' : '') + segments[i];
|
| 1275 |
+
const folderItem = fileTree.querySelector(`.file-tree-item[data-path="${currentPath}"]`);
|
| 1276 |
+
if (folderItem && !folderItem.classList.contains('expanded')) {
|
| 1277 |
+
folderItem.classList.add('expanded');
|
| 1278 |
+
const children = folderItem.querySelector('.file-tree-children');
|
| 1279 |
+
if (children) children.classList.add('expanded');
|
| 1280 |
+
const icon = folderItem.querySelector('.file-tree-icon');
|
| 1281 |
+
if (icon) icon.textContent = 'βΌ';
|
| 1282 |
+
expandedPaths.add(currentPath);
|
| 1283 |
+
}
|
| 1284 |
+
}
|
| 1285 |
+
const targetItem = fileTree.querySelector(`.file-tree-item[data-path="${relPath}"]`);
|
| 1286 |
+
if (targetItem) {
|
| 1287 |
+
targetItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
| 1288 |
+
const line = targetItem.querySelector('.file-tree-line');
|
| 1289 |
+
if (line) {
|
| 1290 |
+
line.classList.add('file-tree-highlight');
|
| 1291 |
+
setTimeout(() => line.classList.remove('file-tree-highlight'), 2000);
|
| 1292 |
+
}
|
| 1293 |
+
}
|
| 1294 |
+
}, 500);
|
| 1295 |
+
}
|
| 1296 |
+
|
| 1297 |
+
// Open files panel when FILES button is clicked
|
| 1298 |
+
if (filesBtn) {
|
| 1299 |
+
filesBtn.addEventListener('click', () => {
|
| 1300 |
+
const isOpening = !filesPanel.classList.contains('active');
|
| 1301 |
+
closeAllPanels();
|
| 1302 |
+
|
| 1303 |
+
if (isOpening) {
|
| 1304 |
+
filesPanel.classList.add('active');
|
| 1305 |
+
filesBtn.classList.add('active');
|
| 1306 |
+
appContainer.classList.add('files-panel-open');
|
| 1307 |
+
loadFileTree();
|
| 1308 |
+
}
|
| 1309 |
+
});
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
// Close files panel
|
| 1313 |
+
if (filesPanelClose) {
|
| 1314 |
+
filesPanelClose.addEventListener('click', () => {
|
| 1315 |
+
filesPanel.classList.remove('active');
|
| 1316 |
+
filesBtn.classList.remove('active');
|
| 1317 |
+
appContainer.classList.remove('files-panel-open');
|
| 1318 |
+
});
|
| 1319 |
+
}
|
| 1320 |
+
|
| 1321 |
+
// Refresh button
|
| 1322 |
+
if (filesRefresh) {
|
| 1323 |
+
filesRefresh.addEventListener('click', () => {
|
| 1324 |
+
loadFileTree();
|
| 1325 |
+
});
|
| 1326 |
+
}
|
| 1327 |
+
|
| 1328 |
+
// Upload to root directory
|
| 1329 |
+
if (filesUpload) {
|
| 1330 |
+
filesUpload.addEventListener('click', () => {
|
| 1331 |
+
const input = document.createElement('input');
|
| 1332 |
+
input.type = 'file';
|
| 1333 |
+
input.addEventListener('change', async () => {
|
| 1334 |
+
if (!input.files.length) return;
|
| 1335 |
+
const formData = new FormData();
|
| 1336 |
+
formData.append('file', input.files[0]);
|
| 1337 |
+
try {
|
| 1338 |
+
await apiFetch('/api/files/upload?folder=', { method: 'POST', body: formData });
|
| 1339 |
+
loadFileTree();
|
| 1340 |
+
} catch (err) {
|
| 1341 |
+
console.error('Upload failed:', err);
|
| 1342 |
+
}
|
| 1343 |
+
});
|
| 1344 |
+
input.click();
|
| 1345 |
+
});
|
| 1346 |
+
}
|
| 1347 |
+
|
| 1348 |
+
// Show hidden files toggle
|
| 1349 |
+
if (showHiddenFiles) {
|
| 1350 |
+
showHiddenFiles.addEventListener('change', () => {
|
| 1351 |
+
loadFileTree();
|
| 1352 |
+
});
|
| 1353 |
+
}
|
| 1354 |
+
|
| 1355 |
+
// Drag & drop upload on files panel
|
| 1356 |
+
if (fileTree) {
|
| 1357 |
+
let dragOverFolder = null;
|
| 1358 |
+
|
| 1359 |
+
fileTree.addEventListener('dragover', (e) => {
|
| 1360 |
+
// Only handle external file drops (not internal path drags)
|
| 1361 |
+
if (!e.dataTransfer.types.includes('Files')) return;
|
| 1362 |
+
e.preventDefault();
|
| 1363 |
+
e.dataTransfer.dropEffect = 'copy';
|
| 1364 |
+
|
| 1365 |
+
// Find folder under cursor
|
| 1366 |
+
const folderItem = e.target.closest('.file-tree-item.folder');
|
| 1367 |
+
if (folderItem) {
|
| 1368 |
+
if (dragOverFolder !== folderItem) {
|
| 1369 |
+
if (dragOverFolder) dragOverFolder.classList.remove('drag-over');
|
| 1370 |
+
fileTree.classList.remove('drag-over-root');
|
| 1371 |
+
folderItem.classList.add('drag-over');
|
| 1372 |
+
dragOverFolder = folderItem;
|
| 1373 |
+
}
|
| 1374 |
+
} else {
|
| 1375 |
+
if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
|
| 1376 |
+
fileTree.classList.add('drag-over-root');
|
| 1377 |
+
}
|
| 1378 |
+
});
|
| 1379 |
+
|
| 1380 |
+
fileTree.addEventListener('dragleave', (e) => {
|
| 1381 |
+
// Only clear when leaving the fileTree entirely
|
| 1382 |
+
if (!fileTree.contains(e.relatedTarget)) {
|
| 1383 |
+
if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
|
| 1384 |
+
fileTree.classList.remove('drag-over-root');
|
| 1385 |
+
}
|
| 1386 |
+
});
|
| 1387 |
+
|
| 1388 |
+
fileTree.addEventListener('drop', async (e) => {
|
| 1389 |
+
if (!e.dataTransfer.files.length) return;
|
| 1390 |
+
e.preventDefault();
|
| 1391 |
+
|
| 1392 |
+
// Determine target folder
|
| 1393 |
+
const folderItem = e.target.closest('.file-tree-item.folder');
|
| 1394 |
+
const folder = folderItem ? folderItem.dataset.path : '';
|
| 1395 |
+
|
| 1396 |
+
// Clear highlights
|
| 1397 |
+
if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
|
| 1398 |
+
fileTree.classList.remove('drag-over-root');
|
| 1399 |
+
|
| 1400 |
+
// Upload all files
|
| 1401 |
+
for (const file of e.dataTransfer.files) {
|
| 1402 |
+
const formData = new FormData();
|
| 1403 |
+
formData.append('file', file);
|
| 1404 |
+
try {
|
| 1405 |
+
await apiFetch(`/api/files/upload?folder=${encodeURIComponent(folder)}`, { method: 'POST', body: formData });
|
| 1406 |
+
} catch (err) {
|
| 1407 |
+
console.error('Upload failed:', err);
|
| 1408 |
+
}
|
| 1409 |
+
}
|
| 1410 |
+
loadFileTree();
|
| 1411 |
+
});
|
| 1412 |
+
}
|
| 1413 |
+
|
| 1414 |
+
// Sessions panel (same pattern as Files/Settings/Debug panels)
|
| 1415 |
+
const sessionsPanel = document.getElementById('sessionsPanel');
|
| 1416 |
+
const sessionsPanelClose = document.getElementById('sessionsPanelClose');
|
| 1417 |
+
const sessionsBtn = document.getElementById('sessionsBtn');
|
| 1418 |
+
|
| 1419 |
+
if (sessionsBtn && sessionsPanel) {
|
| 1420 |
+
sessionsBtn.addEventListener('click', () => {
|
| 1421 |
+
const isOpening = !sessionsPanel.classList.contains('active');
|
| 1422 |
+
closeAllPanels();
|
| 1423 |
+
|
| 1424 |
+
if (isOpening) {
|
| 1425 |
+
sessionsPanel.classList.add('active');
|
| 1426 |
+
sessionsBtn.classList.add('active');
|
| 1427 |
+
appContainer.classList.add('sessions-panel-open');
|
| 1428 |
+
refreshSessionsList();
|
| 1429 |
+
}
|
| 1430 |
+
});
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
if (sessionsPanelClose) {
|
| 1434 |
+
sessionsPanelClose.addEventListener('click', () => {
|
| 1435 |
+
sessionsPanel.classList.remove('active');
|
| 1436 |
+
sessionsBtn.classList.remove('active');
|
| 1437 |
+
appContainer.classList.remove('sessions-panel-open');
|
| 1438 |
+
});
|
| 1439 |
+
}
|
|
@@ -0,0 +1,1279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
async function streamChatResponse(messages, chatContainer, agentType, tabId) {
|
| 2 |
+
const currentSettings = getSettings();
|
| 3 |
+
const backendEndpoint = '/api';
|
| 4 |
+
|
| 5 |
+
// Resolve model configuration for this agent type
|
| 6 |
+
let modelConfig = resolveModelConfig(agentType);
|
| 7 |
+
if (!modelConfig) {
|
| 8 |
+
modelConfig = getDefaultModelConfig();
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
if (!modelConfig) {
|
| 12 |
+
// No models configured - show error
|
| 13 |
+
const errorDiv = document.createElement('div');
|
| 14 |
+
errorDiv.className = 'message assistant error';
|
| 15 |
+
errorDiv.innerHTML = `<div class="message-content">No models configured. Please open Settings and add a provider and model.</div>`;
|
| 16 |
+
chatContainer.appendChild(errorDiv);
|
| 17 |
+
return;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// Resolve research sub-agent model if configured
|
| 21 |
+
let researchSubAgentConfig = null;
|
| 22 |
+
if (currentSettings.researchSubAgentModel) {
|
| 23 |
+
const subAgentModel = currentSettings.models?.[currentSettings.researchSubAgentModel];
|
| 24 |
+
if (subAgentModel) {
|
| 25 |
+
const subAgentProvider = currentSettings.providers?.[subAgentModel.providerId];
|
| 26 |
+
if (subAgentProvider) {
|
| 27 |
+
researchSubAgentConfig = {
|
| 28 |
+
endpoint: subAgentProvider.endpoint,
|
| 29 |
+
token: subAgentProvider.token,
|
| 30 |
+
model: subAgentModel.modelId
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Resolve image model selections to HF model ID strings
|
| 37 |
+
const imageGenModelId = currentSettings.imageGenModel
|
| 38 |
+
? currentSettings.models?.[currentSettings.imageGenModel]?.modelId || null
|
| 39 |
+
: null;
|
| 40 |
+
const imageEditModelId = currentSettings.imageEditModel
|
| 41 |
+
? currentSettings.models?.[currentSettings.imageEditModel]?.modelId || null
|
| 42 |
+
: null;
|
| 43 |
+
|
| 44 |
+
// Set up AbortController for this request
|
| 45 |
+
const abortController = new AbortController();
|
| 46 |
+
if (tabId !== undefined) {
|
| 47 |
+
activeAbortControllers[tabId] = abortController;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
try {
|
| 51 |
+
// Determine parent agent ID for abort propagation
|
| 52 |
+
const parentAgentId = timelineData[tabId]?.parentTabId != null
|
| 53 |
+
? timelineData[tabId].parentTabId.toString()
|
| 54 |
+
: null;
|
| 55 |
+
|
| 56 |
+
const response = await apiFetch(`${backendEndpoint}/chat/stream`, {
|
| 57 |
+
method: 'POST',
|
| 58 |
+
headers: { 'Content-Type': 'application/json' },
|
| 59 |
+
signal: abortController.signal,
|
| 60 |
+
body: JSON.stringify({
|
| 61 |
+
messages: messages,
|
| 62 |
+
agent_type: agentType,
|
| 63 |
+
stream: true,
|
| 64 |
+
endpoint: modelConfig.endpoint,
|
| 65 |
+
token: modelConfig.token || null,
|
| 66 |
+
model: modelConfig.model,
|
| 67 |
+
extra_params: modelConfig.extraParams || null,
|
| 68 |
+
multimodal: modelConfig.multimodal || false,
|
| 69 |
+
e2b_key: currentSettings.e2bKey || null,
|
| 70 |
+
serper_key: currentSettings.serperKey || null,
|
| 71 |
+
hf_token: currentSettings.hfToken || null,
|
| 72 |
+
image_gen_model: imageGenModelId,
|
| 73 |
+
image_edit_model: imageEditModelId,
|
| 74 |
+
research_sub_agent_model: researchSubAgentConfig?.model || null,
|
| 75 |
+
research_sub_agent_endpoint: researchSubAgentConfig?.endpoint || null,
|
| 76 |
+
research_sub_agent_token: researchSubAgentConfig?.token || null,
|
| 77 |
+
research_sub_agent_extra_params: researchSubAgentConfig?.extraParams || null,
|
| 78 |
+
research_parallel_workers: currentSettings.researchParallelWorkers || null,
|
| 79 |
+
research_max_websites: currentSettings.researchMaxWebsites || null,
|
| 80 |
+
agent_id: tabId.toString(), // Send unique tab ID for sandbox sessions
|
| 81 |
+
parent_agent_id: parentAgentId, // Parent agent ID for abort propagation
|
| 82 |
+
frontend_context: getFrontendContext() // Dynamic context for system prompts
|
| 83 |
+
})
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
if (!response.ok) {
|
| 87 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const reader = response.body.getReader();
|
| 91 |
+
const decoder = new TextDecoder();
|
| 92 |
+
let buffer = '';
|
| 93 |
+
let fullResponse = '';
|
| 94 |
+
let currentMessageEl = null;
|
| 95 |
+
let progressHidden = false;
|
| 96 |
+
|
| 97 |
+
// Flush any accumulated assistant text to the timeline
|
| 98 |
+
// Call before adding tool/code/report timeline events to preserve ordering
|
| 99 |
+
function flushResponseToTimeline() {
|
| 100 |
+
if (fullResponse && tabId !== undefined) {
|
| 101 |
+
const evIdx = addTimelineEvent(tabId, 'assistant', fullResponse);
|
| 102 |
+
if (currentMessageEl) currentMessageEl.dataset.timelineIndex = evIdx;
|
| 103 |
+
fullResponse = '';
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
while (true) {
|
| 108 |
+
const { done, value } = await reader.read();
|
| 109 |
+
if (done) break;
|
| 110 |
+
|
| 111 |
+
buffer += decoder.decode(value, { stream: true });
|
| 112 |
+
const lines = buffer.split('\n');
|
| 113 |
+
buffer = lines.pop() || '';
|
| 114 |
+
|
| 115 |
+
for (const line of lines) {
|
| 116 |
+
if (line.startsWith('data: ')) {
|
| 117 |
+
const data = JSON.parse(line.slice(6));
|
| 118 |
+
|
| 119 |
+
// Hide progress widget on first meaningful response
|
| 120 |
+
if (!progressHidden && data.type !== 'generating' && data.type !== 'retry' && !data.type.startsWith('debug_')) {
|
| 121 |
+
hideProgressWidget(chatContainer);
|
| 122 |
+
progressHidden = true;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Handle different message types from backend
|
| 126 |
+
if (data.type === 'thinking') {
|
| 127 |
+
// Assistant thinking - create message if not exists
|
| 128 |
+
if (!currentMessageEl) {
|
| 129 |
+
currentMessageEl = createAssistantMessage(chatContainer);
|
| 130 |
+
}
|
| 131 |
+
fullResponse += data.content;
|
| 132 |
+
appendToMessage(currentMessageEl, resolveGlobalFigureRefs(parseMarkdown(fullResponse)));
|
| 133 |
+
scrollChatToBottom(chatContainer);
|
| 134 |
+
|
| 135 |
+
} else if (data.type === 'code') {
|
| 136 |
+
// Code execution result - update the last code cell
|
| 137 |
+
updateLastCodeCell(chatContainer, data.output, data.error, data.images);
|
| 138 |
+
currentMessageEl = null; // Reset for next thinking
|
| 139 |
+
scrollChatToBottom(chatContainer);
|
| 140 |
+
|
| 141 |
+
} else if (data.type === 'code_start') {
|
| 142 |
+
// Code cell starting execution - show with spinner
|
| 143 |
+
flushResponseToTimeline();
|
| 144 |
+
createCodeCell(chatContainer, data.code, null, false, true);
|
| 145 |
+
currentMessageEl = null;
|
| 146 |
+
scrollChatToBottom(chatContainer);
|
| 147 |
+
// Add to timeline - show code snippet preview
|
| 148 |
+
const codePreview = data.code ? data.code.split('\n')[0] : 'code execution';
|
| 149 |
+
const codeEvIdx = addTimelineEvent(tabId, 'assistant', codePreview, null, { tag: 'CODE' });
|
| 150 |
+
chatContainer.lastElementChild.dataset.timelineIndex = codeEvIdx;
|
| 151 |
+
|
| 152 |
+
} else if (data.type === 'upload') {
|
| 153 |
+
// File upload notification
|
| 154 |
+
flushResponseToTimeline();
|
| 155 |
+
createUploadMessage(chatContainer, data.paths, data.output);
|
| 156 |
+
currentMessageEl = null;
|
| 157 |
+
scrollChatToBottom(chatContainer);
|
| 158 |
+
// Add to timeline
|
| 159 |
+
const upEvIdx = addTimelineEvent(tabId, 'assistant', data.paths?.join(', ') || 'files', null, { tag: 'UPLOAD' });
|
| 160 |
+
chatContainer.lastElementChild.dataset.timelineIndex = upEvIdx;
|
| 161 |
+
|
| 162 |
+
} else if (data.type === 'download') {
|
| 163 |
+
// File download notification
|
| 164 |
+
flushResponseToTimeline();
|
| 165 |
+
createDownloadMessage(chatContainer, data.paths, data.output);
|
| 166 |
+
currentMessageEl = null;
|
| 167 |
+
scrollChatToBottom(chatContainer);
|
| 168 |
+
// Add to timeline
|
| 169 |
+
const dlEvIdx = addTimelineEvent(tabId, 'assistant', data.paths?.join(', ') || 'files', null, { tag: 'DOWNLOAD' });
|
| 170 |
+
chatContainer.lastElementChild.dataset.timelineIndex = dlEvIdx;
|
| 171 |
+
|
| 172 |
+
} else if (data.type === 'generating') {
|
| 173 |
+
// Still generating - no action needed
|
| 174 |
+
|
| 175 |
+
} else if (data.type === 'result') {
|
| 176 |
+
// Populate global figure/image registry only for items referenced in result content
|
| 177 |
+
const resultText = data.content || '';
|
| 178 |
+
if (data.figures) {
|
| 179 |
+
for (const [name, figData] of Object.entries(data.figures)) {
|
| 180 |
+
if (new RegExp(`</?${name}>`, 'i').test(resultText)) {
|
| 181 |
+
globalFigureRegistry[name] = figData;
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
if (data.images) {
|
| 186 |
+
for (const [name, imgBase64] of Object.entries(data.images)) {
|
| 187 |
+
if (new RegExp(`</?${name}>`, 'i').test(resultText)) {
|
| 188 |
+
globalFigureRegistry[name] = { type: 'png', data: imgBase64 };
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
// Agent result - update command center widget
|
| 193 |
+
updateActionWidgetWithResult(tabId, data.content, data.figures, data.images);
|
| 194 |
+
|
| 195 |
+
} else if (data.type === 'result_preview') {
|
| 196 |
+
// Show result preview
|
| 197 |
+
flushResponseToTimeline();
|
| 198 |
+
// Replace <figure_x> tags with placeholders BEFORE markdown processing
|
| 199 |
+
let previewContent = data.content;
|
| 200 |
+
const figurePlaceholders = {};
|
| 201 |
+
|
| 202 |
+
if (data.figures) {
|
| 203 |
+
for (const [figureName, figureData] of Object.entries(data.figures)) {
|
| 204 |
+
// Use %%% delimiters to avoid markdown interpretation
|
| 205 |
+
const placeholderId = `%%%FIGURE_${figureName}%%%`;
|
| 206 |
+
figurePlaceholders[placeholderId] = figureData;
|
| 207 |
+
|
| 208 |
+
// Handle both <figure_x> self-closing and <figure_x></figure_x> pairs
|
| 209 |
+
// First replace paired tags, preserving them as block elements
|
| 210 |
+
const pairedTag = new RegExp(`<${figureName}></${figureName}>`, 'gi');
|
| 211 |
+
previewContent = previewContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
|
| 212 |
+
|
| 213 |
+
// Then replace remaining self-closing tags or orphaned closing tags
|
| 214 |
+
const singleTag = new RegExp(`</?${figureName}>`, 'gi');
|
| 215 |
+
previewContent = previewContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// Handle <image_N> references from image agent
|
| 220 |
+
if (data.images) {
|
| 221 |
+
for (const [imageName, imageBase64] of Object.entries(data.images)) {
|
| 222 |
+
const placeholderId = `%%%IMAGE_${imageName}%%%`;
|
| 223 |
+
figurePlaceholders[placeholderId] = { type: 'png', data: imageBase64, isGenerated: true };
|
| 224 |
+
|
| 225 |
+
const pairedTag = new RegExp(`<${imageName}></${imageName}>`, 'gi');
|
| 226 |
+
previewContent = previewContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
|
| 227 |
+
const singleTag = new RegExp(`</?${imageName}>`, 'gi');
|
| 228 |
+
previewContent = previewContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// Process markdown
|
| 233 |
+
let html = parseMarkdown(previewContent);
|
| 234 |
+
|
| 235 |
+
// Replace placeholders with actual images AFTER markdown processing
|
| 236 |
+
for (const [placeholderId, figureData] of Object.entries(figurePlaceholders)) {
|
| 237 |
+
let imageHtml = '';
|
| 238 |
+
if (figureData.type === 'png' || figureData.type === 'jpeg') {
|
| 239 |
+
imageHtml = `<img src="data:image/${figureData.type};base64,${figureData.data}" style="max-width: 400px; max-height: 400px; height: auto; border-radius: 4px; margin: 12px 0; display: block;" onclick="openImageModal(this.src)">`;
|
| 240 |
+
} else if (figureData.type === 'svg') {
|
| 241 |
+
imageHtml = `<div style="margin: 12px 0;">${atob(figureData.data)}</div>`;
|
| 242 |
+
}
|
| 243 |
+
// Replace both the placeholder and any paragraph-wrapped version
|
| 244 |
+
html = html.replace(new RegExp(`<p>${placeholderId}</p>`, 'g'), imageHtml);
|
| 245 |
+
html = html.replace(new RegExp(placeholderId, 'g'), imageHtml);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// Create result block
|
| 249 |
+
const resultDiv = document.createElement('div');
|
| 250 |
+
resultDiv.className = 'agent-result';
|
| 251 |
+
resultDiv.innerHTML = `
|
| 252 |
+
<div class="result-header">Result</div>
|
| 253 |
+
<div class="result-content">${html}</div>
|
| 254 |
+
`;
|
| 255 |
+
chatContainer.appendChild(resultDiv);
|
| 256 |
+
scrollChatToBottom(chatContainer);
|
| 257 |
+
// Add result dot to parent research timeline
|
| 258 |
+
const figCount = data.figures ? Object.keys(data.figures).length : 0;
|
| 259 |
+
const reportDesc = figCount > 0 ? `${figCount} figures` : (data.content?.replace(/<[^>]+>/g, '').trim().substring(0, 60) || 'done');
|
| 260 |
+
const resEvIdx = addTimelineEvent(tabId, 'assistant', reportDesc, null, { tag: 'RESULT' });
|
| 261 |
+
resultDiv.dataset.timelineIndex = resEvIdx;
|
| 262 |
+
|
| 263 |
+
} else if (data.type === 'status') {
|
| 264 |
+
// Research status update
|
| 265 |
+
createStatusMessage(chatContainer, data.message, data.iteration, data.total_iterations);
|
| 266 |
+
scrollChatToBottom(chatContainer);
|
| 267 |
+
|
| 268 |
+
} else if (data.type === 'queries') {
|
| 269 |
+
// Research queries generated
|
| 270 |
+
createQueriesMessage(chatContainer, data.queries, data.iteration);
|
| 271 |
+
scrollChatToBottom(chatContainer);
|
| 272 |
+
// Register each query as a virtual sub-agent in the timeline
|
| 273 |
+
const startIdx = Object.keys(researchQueryTabIds).length;
|
| 274 |
+
for (let qi = 0; qi < data.queries.length; qi++) {
|
| 275 |
+
const globalIdx = startIdx + qi;
|
| 276 |
+
const virtualId = `research-${tabId}-q${globalIdx}`;
|
| 277 |
+
researchQueryTabIds[globalIdx] = virtualId;
|
| 278 |
+
registerAgentInTimeline(virtualId, 'search', data.queries[qi], tabId);
|
| 279 |
+
setTimelineGenerating(virtualId, true);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
} else if (data.type === 'progress') {
|
| 283 |
+
// Research progress
|
| 284 |
+
updateProgress(chatContainer, data.message, data.websites_visited, data.max_websites);
|
| 285 |
+
scrollChatToBottom(chatContainer);
|
| 286 |
+
|
| 287 |
+
} else if (data.type === 'source') {
|
| 288 |
+
// Research source found - now includes query grouping
|
| 289 |
+
createSourceMessage(chatContainer, data);
|
| 290 |
+
scrollChatToBottom(chatContainer);
|
| 291 |
+
// Add source as dot in virtual sub-agent timeline
|
| 292 |
+
const sourceVirtualId = researchQueryTabIds[data.query_index];
|
| 293 |
+
if (sourceVirtualId) {
|
| 294 |
+
addTimelineEvent(sourceVirtualId, 'assistant', data.title || data.url || 'source');
|
| 295 |
+
} else if (data.query_index === -1) {
|
| 296 |
+
// Browse result β create a virtual browse entry if needed
|
| 297 |
+
const browseId = `research-${tabId}-browse-${Date.now()}`;
|
| 298 |
+
registerAgentInTimeline(browseId, 'browse', data.url || 'webpage', tabId);
|
| 299 |
+
addTimelineEvent(browseId, 'assistant', data.title || data.url || 'page');
|
| 300 |
+
setTimelineGenerating(browseId, false);
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
} else if (data.type === 'query_stats') {
|
| 304 |
+
// Update query statistics
|
| 305 |
+
updateQueryStats(data.query_index, {
|
| 306 |
+
relevant: data.relevant_count,
|
| 307 |
+
irrelevant: data.irrelevant_count,
|
| 308 |
+
error: data.error_count
|
| 309 |
+
});
|
| 310 |
+
// Mark search sub-agent as done
|
| 311 |
+
const statsVirtualId = researchQueryTabIds[data.query_index];
|
| 312 |
+
if (statsVirtualId) {
|
| 313 |
+
setTimelineGenerating(statsVirtualId, false);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
} else if (data.type === 'assessment') {
|
| 317 |
+
// Research completeness assessment
|
| 318 |
+
createAssessmentMessage(chatContainer, data.sufficient, data.missing_aspects, data.findings_count, data.reasoning);
|
| 319 |
+
scrollChatToBottom(chatContainer);
|
| 320 |
+
|
| 321 |
+
} else if (data.type === 'report') {
|
| 322 |
+
// Final research report
|
| 323 |
+
flushResponseToTimeline();
|
| 324 |
+
createReportMessage(chatContainer, data.content, data.sources_count, data.websites_visited);
|
| 325 |
+
scrollChatToBottom(chatContainer);
|
| 326 |
+
// Add to timeline
|
| 327 |
+
const rptEvIdx = addTimelineEvent(tabId, 'assistant', `${data.sources_count || 0} sources, ${data.websites_visited || 0} sites`, null, { tag: 'RESULT' });
|
| 328 |
+
chatContainer.lastElementChild.dataset.timelineIndex = rptEvIdx;
|
| 329 |
+
|
| 330 |
+
} else if (data.type === 'tool_start') {
|
| 331 |
+
// Agent tool execution starting β create a tool-cell box (like code cells)
|
| 332 |
+
flushResponseToTimeline();
|
| 333 |
+
currentMessageEl = null;
|
| 334 |
+
|
| 335 |
+
const toolLabels = {
|
| 336 |
+
'web_search': 'SEARCH',
|
| 337 |
+
'read_url': 'READ',
|
| 338 |
+
'screenshot_url': 'SCREENSHOT',
|
| 339 |
+
'generate_image': 'GENERATE',
|
| 340 |
+
'edit_image': 'EDIT',
|
| 341 |
+
'read_image_url': 'LOAD IMAGE',
|
| 342 |
+
'read_image': 'LOAD IMAGE',
|
| 343 |
+
'show_html': 'HTML'
|
| 344 |
+
};
|
| 345 |
+
const toolDescriptions = {
|
| 346 |
+
'web_search': data.args?.query || '',
|
| 347 |
+
'read_url': data.args?.url || '',
|
| 348 |
+
'screenshot_url': data.args?.url || '',
|
| 349 |
+
'generate_image': data.args?.prompt || '',
|
| 350 |
+
'edit_image': `${data.args?.prompt || ''} (from ${data.args?.source || ''})`,
|
| 351 |
+
'read_image_url': data.args?.url || '',
|
| 352 |
+
'read_image': data.args?.source || '',
|
| 353 |
+
'show_html': data.args?.source?.substring(0, 80) || ''
|
| 354 |
+
};
|
| 355 |
+
const label = toolLabels[data.tool] || data.tool.toUpperCase();
|
| 356 |
+
const description = toolDescriptions[data.tool] || '';
|
| 357 |
+
|
| 358 |
+
// Store tool call in DOM for history reconstruction
|
| 359 |
+
// Reuse currentMessageEl (from thinking) if it exists, like launch events do
|
| 360 |
+
let toolCallMsg = currentMessageEl;
|
| 361 |
+
if (!toolCallMsg) {
|
| 362 |
+
toolCallMsg = document.createElement('div');
|
| 363 |
+
toolCallMsg.className = 'message assistant';
|
| 364 |
+
toolCallMsg.style.display = 'none';
|
| 365 |
+
chatContainer.appendChild(toolCallMsg);
|
| 366 |
+
}
|
| 367 |
+
toolCallMsg.setAttribute('data-tool-call', JSON.stringify({
|
| 368 |
+
tool_call_id: data.tool_call_id,
|
| 369 |
+
function_name: data.tool,
|
| 370 |
+
arguments: data.arguments,
|
| 371 |
+
thinking: data.thinking || ''
|
| 372 |
+
}));
|
| 373 |
+
|
| 374 |
+
// Create tool-cell box (similar to code-cell)
|
| 375 |
+
const toolCell = document.createElement('div');
|
| 376 |
+
toolCell.className = 'tool-cell';
|
| 377 |
+
if (document.getElementById('collapseToolsCheckbox')?.checked) {
|
| 378 |
+
toolCell.classList.add('collapsed');
|
| 379 |
+
}
|
| 380 |
+
toolCell.setAttribute('data-tool-name', data.tool);
|
| 381 |
+
const descHtml = description ? `<span class="tool-cell-desc">${escapeHtml(description)}</span>` : '';
|
| 382 |
+
toolCell.innerHTML = `
|
| 383 |
+
<div class="tool-cell-label"><div class="widget-collapse-toggle${document.getElementById('collapseToolsCheckbox')?.checked ? ' collapsed' : ''}"></div><span>${label}</span>${descHtml}${createSpinnerHtml()}</div>
|
| 384 |
+
<div class="tool-cell-input">${escapeHtml(description)}</div>
|
| 385 |
+
`;
|
| 386 |
+
setupCollapseToggle(toolCell, '.tool-cell-label');
|
| 387 |
+
chatContainer.appendChild(toolCell);
|
| 388 |
+
scrollChatToBottom(chatContainer);
|
| 389 |
+
const toolEvIdx = addTimelineEvent(tabId, 'assistant', description, null, { tag: label });
|
| 390 |
+
toolCell.dataset.timelineIndex = toolEvIdx;
|
| 391 |
+
|
| 392 |
+
} else if (data.type === 'tool_result') {
|
| 393 |
+
// Agent tool result β populate the last tool-cell with output
|
| 394 |
+
const lastToolCell = chatContainer.querySelector('.tool-cell:last-of-type');
|
| 395 |
+
|
| 396 |
+
// Remove spinner
|
| 397 |
+
if (lastToolCell) {
|
| 398 |
+
const spinner = lastToolCell.querySelector('.tool-spinner');
|
| 399 |
+
if (spinner) spinner.remove();
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
// Store tool response in DOM for history reconstruction
|
| 403 |
+
const toolResponseMsg = document.createElement('div');
|
| 404 |
+
toolResponseMsg.className = 'message tool';
|
| 405 |
+
toolResponseMsg.style.display = 'none';
|
| 406 |
+
toolResponseMsg.setAttribute('data-tool-response', JSON.stringify({
|
| 407 |
+
tool_call_id: data.tool_call_id,
|
| 408 |
+
content: data.response || ''
|
| 409 |
+
}));
|
| 410 |
+
chatContainer.appendChild(toolResponseMsg);
|
| 411 |
+
|
| 412 |
+
// Build output HTML based on tool type
|
| 413 |
+
let outputHtml = '';
|
| 414 |
+
|
| 415 |
+
if (data.tool === 'web_search' && data.result?.results) {
|
| 416 |
+
try {
|
| 417 |
+
const results = typeof data.result.results === 'string' ? JSON.parse(data.result.results) : data.result.results;
|
| 418 |
+
if (Array.isArray(results)) {
|
| 419 |
+
outputHtml = '<div class="search-results-content">' +
|
| 420 |
+
results.map(r =>
|
| 421 |
+
`<div class="search-result-item"><a href="${escapeHtml(r.url)}" target="_blank">${escapeHtml(r.title)}</a><span class="search-snippet">${escapeHtml(r.snippet)}</span></div>`
|
| 422 |
+
).join('') + '</div>';
|
| 423 |
+
}
|
| 424 |
+
} catch(e) { /* ignore parse errors */ }
|
| 425 |
+
} else if (data.tool === 'read_url') {
|
| 426 |
+
const len = data.result?.length || 0;
|
| 427 |
+
const markdown = data.result?.markdown || '';
|
| 428 |
+
const summaryText = len > 0 ? `Extracted ${(len / 1000).toFixed(1)}k chars` : 'No content extracted';
|
| 429 |
+
if (markdown) {
|
| 430 |
+
const toggleId = `read-content-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
| 431 |
+
outputHtml = `<div class="tool-cell-read-summary">${summaryText} <button class="read-content-toggle" onclick="const el=document.getElementById('${toggleId}');el.classList.toggle('expanded');this.textContent=el.classList.contains('expanded')?'Hide':'Show content'">Show content</button></div><div id="${toggleId}" class="read-content-body">${parseMarkdown(markdown)}</div>`;
|
| 432 |
+
} else {
|
| 433 |
+
outputHtml = `<div class="tool-cell-read-summary">${summaryText}</div>`;
|
| 434 |
+
}
|
| 435 |
+
} else if (data.tool === 'screenshot_url' && data.image) {
|
| 436 |
+
outputHtml = `<img src="data:image/png;base64,${data.image}" alt="Screenshot" class="screenshot-img" />`;
|
| 437 |
+
} else if ((data.tool === 'generate_image' || data.tool === 'edit_image' || data.tool === 'read_image_url' || data.tool === 'read_image') && data.image) {
|
| 438 |
+
const imgName = data.image_name || 'image';
|
| 439 |
+
outputHtml = `<img src="data:image/png;base64,${data.image}" alt="${escapeHtml(imgName)}" class="generated-img" />`;
|
| 440 |
+
} else if ((data.tool === 'generate_image' || data.tool === 'edit_image' || data.tool === 'read_image_url' || data.tool === 'read_image') && !data.image) {
|
| 441 |
+
const errMsg = data.response || 'Failed to process image';
|
| 442 |
+
outputHtml = `<div class="tool-cell-read-summary">${escapeHtml(errMsg)}</div>`;
|
| 443 |
+
} else if (data.tool === 'show_html' && data.result?.html) {
|
| 444 |
+
// Create iframe programmatically to avoid escaping issues with srcdoc
|
| 445 |
+
if (lastToolCell) {
|
| 446 |
+
const outputEl = document.createElement('div');
|
| 447 |
+
outputEl.className = 'tool-cell-output';
|
| 448 |
+
const iframe = document.createElement('iframe');
|
| 449 |
+
iframe.className = 'show-html-iframe';
|
| 450 |
+
iframe.sandbox = 'allow-scripts allow-same-origin';
|
| 451 |
+
iframe.srcdoc = data.result.html;
|
| 452 |
+
outputEl.appendChild(iframe);
|
| 453 |
+
lastToolCell.appendChild(outputEl);
|
| 454 |
+
}
|
| 455 |
+
} else if (data.tool === 'show_html' && !data.result?.html) {
|
| 456 |
+
const errMsg = data.response || 'Failed to load HTML';
|
| 457 |
+
outputHtml = `<div class="tool-cell-read-summary">${escapeHtml(errMsg)}</div>`;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
if (outputHtml && lastToolCell) {
|
| 461 |
+
const outputEl = document.createElement('div');
|
| 462 |
+
outputEl.className = 'tool-cell-output';
|
| 463 |
+
outputEl.innerHTML = outputHtml;
|
| 464 |
+
lastToolCell.appendChild(outputEl);
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
scrollChatToBottom(chatContainer);
|
| 468 |
+
|
| 469 |
+
} else if (data.type === 'content') {
|
| 470 |
+
// Regular streaming content (non-code agents)
|
| 471 |
+
if (!currentMessageEl) {
|
| 472 |
+
currentMessageEl = createAssistantMessage(chatContainer);
|
| 473 |
+
}
|
| 474 |
+
fullResponse += data.content;
|
| 475 |
+
appendToMessage(currentMessageEl, resolveGlobalFigureRefs(parseMarkdown(fullResponse)));
|
| 476 |
+
scrollChatToBottom(chatContainer);
|
| 477 |
+
|
| 478 |
+
} else if (data.type === 'launch') {
|
| 479 |
+
// Tool-based agent launch from command center
|
| 480 |
+
pendingAgentLaunches++;
|
| 481 |
+
const agentType = data.agent_type;
|
| 482 |
+
const initialMessage = data.initial_message;
|
| 483 |
+
const taskId = data.task_id;
|
| 484 |
+
const toolCallId = data.tool_call_id;
|
| 485 |
+
|
| 486 |
+
// Add tool call data to existing message (from thinking) or create new one
|
| 487 |
+
// This keeps content + tool_calls together for proper history reconstruction
|
| 488 |
+
let toolCallMsg = currentMessageEl;
|
| 489 |
+
if (!toolCallMsg) {
|
| 490 |
+
toolCallMsg = document.createElement('div');
|
| 491 |
+
toolCallMsg.className = 'message assistant';
|
| 492 |
+
toolCallMsg.innerHTML = '<div class="message-content"></div>';
|
| 493 |
+
chatContainer.appendChild(toolCallMsg);
|
| 494 |
+
}
|
| 495 |
+
toolCallMsg.setAttribute('data-tool-call', JSON.stringify({
|
| 496 |
+
agent_type: agentType,
|
| 497 |
+
message: initialMessage,
|
| 498 |
+
tool_call_id: toolCallId
|
| 499 |
+
}));
|
| 500 |
+
|
| 501 |
+
// Add tool response so LLM knows the tool was executed
|
| 502 |
+
const toolResponseMsg = document.createElement('div');
|
| 503 |
+
toolResponseMsg.className = 'message tool';
|
| 504 |
+
toolResponseMsg.style.display = 'none';
|
| 505 |
+
toolResponseMsg.setAttribute('data-tool-response', JSON.stringify({
|
| 506 |
+
tool_call_id: toolCallId,
|
| 507 |
+
content: `Launched ${agentType} agent with task: ${initialMessage}`
|
| 508 |
+
}));
|
| 509 |
+
chatContainer.appendChild(toolResponseMsg);
|
| 510 |
+
|
| 511 |
+
// The action widget will show the launch visually
|
| 512 |
+
handleActionToken(agentType, initialMessage, (targetTabId) => {
|
| 513 |
+
showActionWidget(chatContainer, agentType, initialMessage, targetTabId, taskId);
|
| 514 |
+
// Store tool call ID for this agent tab so we can send result back
|
| 515 |
+
toolCallIds[targetTabId] = toolCallId;
|
| 516 |
+
}, taskId, tabId);
|
| 517 |
+
|
| 518 |
+
// Reset current message element so any subsequent thinking starts fresh
|
| 519 |
+
currentMessageEl = null;
|
| 520 |
+
|
| 521 |
+
} else if (data.type === 'debug_call_input') {
|
| 522 |
+
// Debug: LLM call input (before API call)
|
| 523 |
+
if (!debugHistory[tabId]) debugHistory[tabId] = [];
|
| 524 |
+
debugHistory[tabId].push({
|
| 525 |
+
call_number: data.call_number,
|
| 526 |
+
timestamp: new Date().toLocaleTimeString(),
|
| 527 |
+
input: data.messages,
|
| 528 |
+
output: null,
|
| 529 |
+
error: null
|
| 530 |
+
});
|
| 531 |
+
if (document.getElementById('debugPanel')?.classList.contains('active')) loadDebugMessages();
|
| 532 |
+
|
| 533 |
+
} else if (data.type === 'debug_call_output') {
|
| 534 |
+
// Debug: LLM call output (after API call)
|
| 535 |
+
// Match the last pending call (call_numbers reset per streaming request)
|
| 536 |
+
const calls = debugHistory[tabId] || [];
|
| 537 |
+
const call = calls.findLast(c => c.output === null && c.error === null);
|
| 538 |
+
if (call) {
|
| 539 |
+
call.output = data.response || null;
|
| 540 |
+
call.error = data.error || null;
|
| 541 |
+
}
|
| 542 |
+
if (document.getElementById('debugPanel')?.classList.contains('active')) loadDebugMessages();
|
| 543 |
+
|
| 544 |
+
} else if (data.type === 'aborted') {
|
| 545 |
+
// Agent was aborted by user
|
| 546 |
+
hideProgressWidget(chatContainer);
|
| 547 |
+
removeRetryIndicator(chatContainer);
|
| 548 |
+
|
| 549 |
+
// Mark the action widget as aborted (show Γ instead of β)
|
| 550 |
+
const widget = actionWidgets[tabId];
|
| 551 |
+
if (widget) {
|
| 552 |
+
const orbitIndicator = widget.querySelector('.orbit-indicator');
|
| 553 |
+
if (orbitIndicator) {
|
| 554 |
+
const abortedIndicator = document.createElement('div');
|
| 555 |
+
abortedIndicator.className = 'done-indicator aborted';
|
| 556 |
+
orbitIndicator.replaceWith(abortedIndicator);
|
| 557 |
+
}
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
// For timeline agent boxes, mark as aborted
|
| 561 |
+
if (timelineData[tabId]) {
|
| 562 |
+
timelineData[tabId].aborted = true;
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
break; // Stop reading stream
|
| 566 |
+
|
| 567 |
+
} else if (data.type === 'done') {
|
| 568 |
+
// Remove retry indicator on success
|
| 569 |
+
removeRetryIndicator(chatContainer);
|
| 570 |
+
|
| 571 |
+
// Reset research state when research agent completes
|
| 572 |
+
if (agentType === 'research' && typeof resetResearchState === 'function') {
|
| 573 |
+
// Mark all research virtual sub-agents as done
|
| 574 |
+
for (const virtualId of Object.values(researchQueryTabIds)) {
|
| 575 |
+
setTimelineGenerating(virtualId, false);
|
| 576 |
+
}
|
| 577 |
+
researchQueryTabIds = {};
|
| 578 |
+
resetResearchState();
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
// Check for action tokens in regular agents (legacy support)
|
| 582 |
+
if (fullResponse) {
|
| 583 |
+
const actionMatch = fullResponse.match(/<action:(agent|code|research|chat)>([\s\S]*?)<\/action>/i);
|
| 584 |
+
if (actionMatch) {
|
| 585 |
+
const action = actionMatch[1].toLowerCase();
|
| 586 |
+
const actionMessage = actionMatch[2].trim();
|
| 587 |
+
|
| 588 |
+
// Remove action token from fullResponse
|
| 589 |
+
const cleanedResponse = fullResponse.replace(/<action:(agent|code|research|chat)>[\s\S]*?<\/action>/i, '').trim();
|
| 590 |
+
|
| 591 |
+
// Update the display to remove action tags
|
| 592 |
+
if (cleanedResponse.length === 0 && currentMessageEl) {
|
| 593 |
+
currentMessageEl.remove();
|
| 594 |
+
} else if (currentMessageEl) {
|
| 595 |
+
// Clear and replace the entire message content
|
| 596 |
+
const messageContent = currentMessageEl.querySelector('.message-content');
|
| 597 |
+
if (messageContent) {
|
| 598 |
+
messageContent.innerHTML = parseMarkdown(cleanedResponse);
|
| 599 |
+
linkifyFilePaths(messageContent);
|
| 600 |
+
}
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
handleActionToken(action, actionMessage, (targetTabId) => {
|
| 604 |
+
showActionWidget(chatContainer, action, actionMessage, targetTabId);
|
| 605 |
+
}, null, tabId);
|
| 606 |
+
}
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
} else if (data.type === 'info') {
|
| 610 |
+
// Info message (e.g., sandbox restart)
|
| 611 |
+
const infoDiv = document.createElement('div');
|
| 612 |
+
infoDiv.className = 'system-message';
|
| 613 |
+
infoDiv.innerHTML = `<em>${escapeHtml(data.content)}</em>`;
|
| 614 |
+
infoDiv.style.color = 'var(--theme-accent)';
|
| 615 |
+
chatContainer.appendChild(infoDiv);
|
| 616 |
+
scrollChatToBottom(chatContainer);
|
| 617 |
+
|
| 618 |
+
} else if (data.type === 'retry') {
|
| 619 |
+
// Show retry indicator
|
| 620 |
+
showRetryIndicator(chatContainer, data);
|
| 621 |
+
|
| 622 |
+
} else if (data.type === 'error') {
|
| 623 |
+
// Remove any retry indicator before showing error
|
| 624 |
+
removeRetryIndicator(chatContainer);
|
| 625 |
+
const errorDiv = document.createElement('div');
|
| 626 |
+
errorDiv.className = 'message assistant';
|
| 627 |
+
errorDiv.innerHTML = `<div class="message-content" style="color: #c62828;">Error: ${escapeHtml(data.content)}</div>`;
|
| 628 |
+
chatContainer.appendChild(errorDiv);
|
| 629 |
+
scrollChatToBottom(chatContainer);
|
| 630 |
+
|
| 631 |
+
// Propagate error to parent action widget
|
| 632 |
+
updateActionWidgetWithResult(tabId, `Error: ${data.content}`, {}, {});
|
| 633 |
+
const errorWidget = actionWidgets[tabId];
|
| 634 |
+
if (errorWidget) {
|
| 635 |
+
const doneIndicator = errorWidget.querySelector('.done-indicator');
|
| 636 |
+
if (doneIndicator) {
|
| 637 |
+
doneIndicator.classList.add('errored');
|
| 638 |
+
}
|
| 639 |
+
}
|
| 640 |
+
}
|
| 641 |
+
}
|
| 642 |
+
}
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
// Add assistant response to timeline
|
| 646 |
+
if (fullResponse && tabId !== undefined) {
|
| 647 |
+
const finalEvIdx = addTimelineEvent(tabId, 'assistant', fullResponse);
|
| 648 |
+
if (currentMessageEl) currentMessageEl.dataset.timelineIndex = finalEvIdx;
|
| 649 |
+
}
|
| 650 |
+
} catch (error) {
|
| 651 |
+
hideProgressWidget(chatContainer);
|
| 652 |
+
if (error.name === 'AbortError') {
|
| 653 |
+
// User-initiated abort β show as a result block
|
| 654 |
+
const abortResultText = 'Generation aborted by user.';
|
| 655 |
+
const resultDiv = document.createElement('div');
|
| 656 |
+
resultDiv.className = 'agent-result';
|
| 657 |
+
resultDiv.innerHTML = `
|
| 658 |
+
<div class="result-header">Result</div>
|
| 659 |
+
<div class="result-content"><p>${abortResultText}</p></div>
|
| 660 |
+
`;
|
| 661 |
+
chatContainer.appendChild(resultDiv);
|
| 662 |
+
|
| 663 |
+
// Send abort result to parent action widget (so command center knows it was aborted)
|
| 664 |
+
updateActionWidgetWithResult(tabId, abortResultText, {}, {});
|
| 665 |
+
|
| 666 |
+
// Override the done indicator to show Γ instead of β
|
| 667 |
+
const widget = actionWidgets[tabId];
|
| 668 |
+
if (widget) {
|
| 669 |
+
const doneIndicator = widget.querySelector('.done-indicator');
|
| 670 |
+
if (doneIndicator) {
|
| 671 |
+
doneIndicator.classList.add('aborted');
|
| 672 |
+
}
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
// Mark timeline data as aborted
|
| 676 |
+
if (timelineData[tabId]) {
|
| 677 |
+
timelineData[tabId].aborted = true;
|
| 678 |
+
}
|
| 679 |
+
} else {
|
| 680 |
+
const errorDiv = document.createElement('div');
|
| 681 |
+
errorDiv.className = 'message assistant';
|
| 682 |
+
errorDiv.innerHTML = `<div class="message-content" style="color: #c62828;">Connection error: ${escapeHtml(error.message)}</div>`;
|
| 683 |
+
chatContainer.appendChild(errorDiv);
|
| 684 |
+
}
|
| 685 |
+
if (tabId) {
|
| 686 |
+
setTabGenerating(tabId, false);
|
| 687 |
+
}
|
| 688 |
+
} finally {
|
| 689 |
+
// Clean up abort controller
|
| 690 |
+
delete activeAbortControllers[tabId];
|
| 691 |
+
}
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
function createAssistantMessage(chatContainer) {
|
| 695 |
+
const msg = document.createElement('div');
|
| 696 |
+
msg.className = 'message assistant';
|
| 697 |
+
msg.innerHTML = '<div class="message-content"></div>';
|
| 698 |
+
chatContainer.appendChild(msg);
|
| 699 |
+
return msg;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
function appendToMessage(messageEl, content) {
|
| 703 |
+
const contentEl = messageEl.querySelector('.message-content');
|
| 704 |
+
if (contentEl) {
|
| 705 |
+
contentEl.innerHTML = content;
|
| 706 |
+
linkifyFilePaths(contentEl);
|
| 707 |
+
}
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
function createSpinnerHtml() {
|
| 711 |
+
return `<div class="tool-spinner"><span></span><span></span><span></span></div>`;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
function createCodeCell(chatContainer, code, output, isError, isExecuting = false) {
|
| 715 |
+
const codeCell = document.createElement('div');
|
| 716 |
+
codeCell.className = 'code-cell';
|
| 717 |
+
|
| 718 |
+
let outputHtml = '';
|
| 719 |
+
const cleanedOutput = cleanCodeOutput(output);
|
| 720 |
+
if (cleanedOutput && !isExecuting) {
|
| 721 |
+
outputHtml = `<div class="code-cell-output${isError ? ' error' : ''}">${escapeHtml(cleanedOutput)}</div>`;
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
const spinnerHtml = isExecuting ? createSpinnerHtml() : '';
|
| 725 |
+
|
| 726 |
+
codeCell.innerHTML = `
|
| 727 |
+
<div class="code-cell-label"><div class="widget-collapse-toggle"></div><span>CODE</span>${spinnerHtml}</div>
|
| 728 |
+
<div class="code-cell-code"><pre><code class="language-python">${escapeHtml(code)}</code></pre></div>
|
| 729 |
+
${outputHtml}
|
| 730 |
+
`;
|
| 731 |
+
setupCollapseToggle(codeCell, '.code-cell-label');
|
| 732 |
+
chatContainer.appendChild(codeCell);
|
| 733 |
+
|
| 734 |
+
// Apply syntax highlighting
|
| 735 |
+
if (typeof Prism !== 'undefined') {
|
| 736 |
+
const codeBlock = codeCell.querySelector('code.language-python');
|
| 737 |
+
if (codeBlock) {
|
| 738 |
+
Prism.highlightElement(codeBlock);
|
| 739 |
+
}
|
| 740 |
+
}
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
function createFileTransferCell(chatContainer, type, paths, output, isExecuting = false) {
|
| 744 |
+
const cell = document.createElement('div');
|
| 745 |
+
cell.className = 'action-widget';
|
| 746 |
+
|
| 747 |
+
const label = type === 'upload' ? 'UPLOAD' : 'DOWNLOAD';
|
| 748 |
+
const hasError = output && output.includes('Error:');
|
| 749 |
+
|
| 750 |
+
// Indicator: spinner while executing, checkmark when done
|
| 751 |
+
const indicatorHtml = isExecuting
|
| 752 |
+
? `<div class="orbit-indicator"><span></span><span></span><span></span></div>`
|
| 753 |
+
: `<div class="done-indicator"></div>`;
|
| 754 |
+
|
| 755 |
+
// Format paths as list items (make local paths clickable for downloads)
|
| 756 |
+
const pathsList = paths.map(p => {
|
| 757 |
+
if (type === 'download') {
|
| 758 |
+
const arrowIdx = p.indexOf(' -> ');
|
| 759 |
+
if (arrowIdx !== -1) {
|
| 760 |
+
const localPath = p.substring(arrowIdx + 4);
|
| 761 |
+
return `<li>${escapeHtml(p.substring(0, arrowIdx + 4))}<a class="transfer-path-link" href="#" data-path="${escapeHtml(localPath)}">${escapeHtml(localPath)}</a></li>`;
|
| 762 |
+
}
|
| 763 |
+
}
|
| 764 |
+
return `<li>${escapeHtml(p)}</li>`;
|
| 765 |
+
}).join('');
|
| 766 |
+
|
| 767 |
+
let outputHtml = '';
|
| 768 |
+
if (output && !isExecuting) {
|
| 769 |
+
const outputClass = hasError ? 'transfer-output error' : 'transfer-output';
|
| 770 |
+
outputHtml = `<div class="${outputClass}">${escapeHtml(output)}</div>`;
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
cell.innerHTML = `
|
| 774 |
+
<div class="action-widget-header">
|
| 775 |
+
<div class="widget-collapse-toggle"></div>
|
| 776 |
+
<span class="action-widget-type">${label}</span>
|
| 777 |
+
<div class="action-widget-bar-right">${indicatorHtml}</div>
|
| 778 |
+
</div>
|
| 779 |
+
<div class="action-widget-body">
|
| 780 |
+
<ul class="transfer-paths">${pathsList}</ul>
|
| 781 |
+
${outputHtml}
|
| 782 |
+
</div>
|
| 783 |
+
`;
|
| 784 |
+
cell.querySelector('.action-widget-header').style.cursor = 'pointer';
|
| 785 |
+
cell.querySelector('.action-widget-header').addEventListener('click', () => {
|
| 786 |
+
cell.classList.toggle('collapsed');
|
| 787 |
+
cell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed');
|
| 788 |
+
});
|
| 789 |
+
// Make download path links clickable to navigate in file explorer
|
| 790 |
+
cell.querySelectorAll('.transfer-path-link').forEach(link => {
|
| 791 |
+
link.addEventListener('click', (e) => {
|
| 792 |
+
e.preventDefault();
|
| 793 |
+
navigateToFileInExplorer(link.dataset.path);
|
| 794 |
+
});
|
| 795 |
+
});
|
| 796 |
+
chatContainer.appendChild(cell);
|
| 797 |
+
return cell;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
function createUploadMessage(chatContainer, paths, output) {
|
| 801 |
+
createFileTransferCell(chatContainer, 'upload', paths, output, false);
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
function createDownloadMessage(chatContainer, paths, output) {
|
| 805 |
+
createFileTransferCell(chatContainer, 'download', paths, output, false);
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
function updateLastCodeCell(chatContainer, output, isError, images) {
|
| 809 |
+
const codeCells = chatContainer.querySelectorAll('.code-cell');
|
| 810 |
+
if (codeCells.length === 0) return;
|
| 811 |
+
|
| 812 |
+
const lastCell = codeCells[codeCells.length - 1];
|
| 813 |
+
|
| 814 |
+
// Remove spinner if present
|
| 815 |
+
const spinner = lastCell.querySelector('.tool-spinner');
|
| 816 |
+
if (spinner) {
|
| 817 |
+
spinner.remove();
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
// Remove existing output if any
|
| 821 |
+
const existingOutput = lastCell.querySelector('.code-cell-output');
|
| 822 |
+
if (existingOutput) {
|
| 823 |
+
existingOutput.remove();
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
// Add images if any
|
| 827 |
+
if (images && images.length > 0) {
|
| 828 |
+
for (const img of images) {
|
| 829 |
+
const imgDiv = document.createElement('div');
|
| 830 |
+
imgDiv.className = 'code-cell-image';
|
| 831 |
+
|
| 832 |
+
// Add figure label if available
|
| 833 |
+
if (img.name) {
|
| 834 |
+
const label = document.createElement('div');
|
| 835 |
+
label.className = 'figure-label';
|
| 836 |
+
label.textContent = img.name;
|
| 837 |
+
imgDiv.appendChild(label);
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
if (img.type === 'png' || img.type === 'jpeg') {
|
| 841 |
+
const imgEl = document.createElement('img');
|
| 842 |
+
imgEl.src = `data:image/${img.type};base64,${img.data}`;
|
| 843 |
+
imgEl.onclick = function() { openImageModal(this.src); };
|
| 844 |
+
imgDiv.appendChild(imgEl);
|
| 845 |
+
} else if (img.type === 'svg') {
|
| 846 |
+
const svgContainer = document.createElement('div');
|
| 847 |
+
svgContainer.innerHTML = atob(img.data);
|
| 848 |
+
imgDiv.appendChild(svgContainer);
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
lastCell.appendChild(imgDiv);
|
| 852 |
+
}
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
// Add text output
|
| 856 |
+
const cleanedOut = cleanCodeOutput(output);
|
| 857 |
+
if (cleanedOut) {
|
| 858 |
+
const outputDiv = document.createElement('div');
|
| 859 |
+
outputDiv.className = `code-cell-output${isError ? ' error' : ''}`;
|
| 860 |
+
outputDiv.textContent = cleanedOut;
|
| 861 |
+
lastCell.appendChild(outputDiv);
|
| 862 |
+
}
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
function showActionWidget(chatContainer, action, message, targetTabId, taskId = null) {
|
| 866 |
+
const widget = document.createElement('div');
|
| 867 |
+
widget.className = 'action-widget';
|
| 868 |
+
if (document.getElementById('collapseAgentsCheckbox')?.checked) {
|
| 869 |
+
widget.classList.add('collapsed');
|
| 870 |
+
}
|
| 871 |
+
widget.dataset.targetTabId = targetTabId;
|
| 872 |
+
|
| 873 |
+
// Display task_id as title if provided
|
| 874 |
+
const titleDisplay = taskId ? taskId : action.toUpperCase();
|
| 875 |
+
const agentCollapsed = document.getElementById('collapseAgentsCheckbox')?.checked;
|
| 876 |
+
|
| 877 |
+
widget.innerHTML = `
|
| 878 |
+
<div class="action-widget-clickable">
|
| 879 |
+
<div class="action-widget-header">
|
| 880 |
+
<div class="widget-collapse-toggle${agentCollapsed ? ' collapsed' : ''}"></div>
|
| 881 |
+
<span class="action-widget-type">${action.toUpperCase()}: ${escapeHtml(titleDisplay)}</span>
|
| 882 |
+
<div class="action-widget-bar-right">
|
| 883 |
+
<div class="orbit-indicator">
|
| 884 |
+
<span></span><span></span><span></span>
|
| 885 |
+
</div>
|
| 886 |
+
</div>
|
| 887 |
+
</div>
|
| 888 |
+
</div>
|
| 889 |
+
<div class="action-widget-body">
|
| 890 |
+
<div class="section-label">QUERY</div>
|
| 891 |
+
<div class="section-content">${parseMarkdown(message)}</div>
|
| 892 |
+
</div>
|
| 893 |
+
`;
|
| 894 |
+
|
| 895 |
+
// Collapse toggle β stop propagation so it doesn't navigate to the agent tab
|
| 896 |
+
const collapseToggle = widget.querySelector('.widget-collapse-toggle');
|
| 897 |
+
collapseToggle.addEventListener('click', (e) => {
|
| 898 |
+
e.stopPropagation();
|
| 899 |
+
widget.classList.toggle('collapsed');
|
| 900 |
+
collapseToggle.classList.toggle('collapsed');
|
| 901 |
+
});
|
| 902 |
+
|
| 903 |
+
// Make header clickable to jump to the agent
|
| 904 |
+
const clickableArea = widget.querySelector('.action-widget-clickable');
|
| 905 |
+
|
| 906 |
+
const clickHandler = () => {
|
| 907 |
+
const tabId = parseInt(targetTabId);
|
| 908 |
+
// Check if the tab still exists (use .tab to avoid matching timeline elements)
|
| 909 |
+
const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
|
| 910 |
+
if (tab) {
|
| 911 |
+
// Tab exists, just switch to it
|
| 912 |
+
switchToTab(tabId);
|
| 913 |
+
} else {
|
| 914 |
+
// Tab was closed - restore from timeline data
|
| 915 |
+
const notebook = timelineData[tabId];
|
| 916 |
+
if (notebook) {
|
| 917 |
+
reopenClosedTab(tabId, notebook);
|
| 918 |
+
}
|
| 919 |
+
}
|
| 920 |
+
};
|
| 921 |
+
|
| 922 |
+
clickableArea.addEventListener('click', clickHandler);
|
| 923 |
+
|
| 924 |
+
chatContainer.appendChild(widget);
|
| 925 |
+
scrollChatToBottom(chatContainer);
|
| 926 |
+
|
| 927 |
+
// Store widget for later updates
|
| 928 |
+
actionWidgets[targetTabId] = widget;
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
async function updateActionWidgetWithResult(tabId, resultContent, figures, images) {
|
| 932 |
+
const widget = actionWidgets[tabId];
|
| 933 |
+
if (!widget) return;
|
| 934 |
+
|
| 935 |
+
// Replace orbiting dots with checkmark
|
| 936 |
+
const orbitIndicator = widget.querySelector('.orbit-indicator');
|
| 937 |
+
if (orbitIndicator) {
|
| 938 |
+
const doneIndicator = document.createElement('div');
|
| 939 |
+
doneIndicator.className = 'done-indicator';
|
| 940 |
+
orbitIndicator.replaceWith(doneIndicator);
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
// Process and display result content FIRST (before async operations)
|
| 944 |
+
// Replace <figure_x> tags with placeholders BEFORE markdown processing
|
| 945 |
+
let processedContent = resultContent;
|
| 946 |
+
const figurePlaceholders = {};
|
| 947 |
+
|
| 948 |
+
if (figures) {
|
| 949 |
+
for (const [figureName, figureData] of Object.entries(figures)) {
|
| 950 |
+
// Use %%% delimiters to avoid markdown interpretation
|
| 951 |
+
const placeholderId = `%%%FIGURE_${figureName}%%%`;
|
| 952 |
+
figurePlaceholders[placeholderId] = figureData;
|
| 953 |
+
|
| 954 |
+
// Handle both <figure_x> self-closing and <figure_x></figure_x> pairs
|
| 955 |
+
// First replace paired tags, preserving them as block elements
|
| 956 |
+
const pairedTag = new RegExp(`<${figureName}></${figureName}>`, 'gi');
|
| 957 |
+
processedContent = processedContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
|
| 958 |
+
|
| 959 |
+
// Then replace remaining self-closing tags or orphaned closing tags
|
| 960 |
+
const singleTag = new RegExp(`</?${figureName}>`, 'gi');
|
| 961 |
+
processedContent = processedContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
|
| 962 |
+
}
|
| 963 |
+
}
|
| 964 |
+
|
| 965 |
+
// Handle <image_N> references from image agent
|
| 966 |
+
if (images) {
|
| 967 |
+
for (const [imageName, imageBase64] of Object.entries(images)) {
|
| 968 |
+
const placeholderId = `%%%IMAGE_${imageName}%%%`;
|
| 969 |
+
figurePlaceholders[placeholderId] = { type: 'png', data: imageBase64 };
|
| 970 |
+
|
| 971 |
+
const pairedTag = new RegExp(`<${imageName}></${imageName}>`, 'gi');
|
| 972 |
+
processedContent = processedContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
|
| 973 |
+
const singleTag = new RegExp(`</?${imageName}>`, 'gi');
|
| 974 |
+
processedContent = processedContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
|
| 975 |
+
}
|
| 976 |
+
}
|
| 977 |
+
|
| 978 |
+
// Process markdown
|
| 979 |
+
let html = parseMarkdown(processedContent);
|
| 980 |
+
|
| 981 |
+
// Replace placeholders with actual images AFTER markdown processing
|
| 982 |
+
for (const [placeholderId, figureData] of Object.entries(figurePlaceholders)) {
|
| 983 |
+
let imageHtml = '';
|
| 984 |
+
if (figureData.type === 'png' || figureData.type === 'jpeg') {
|
| 985 |
+
imageHtml = `<img src="data:image/${figureData.type};base64,${figureData.data}" style="max-width: 400px; max-height: 400px; height: auto; border-radius: 4px; margin: 12px 0; display: block;" onclick="openImageModal(this.src)">`;
|
| 986 |
+
} else if (figureData.type === 'svg') {
|
| 987 |
+
imageHtml = `<div style="margin: 12px 0;">${atob(figureData.data)}</div>`;
|
| 988 |
+
}
|
| 989 |
+
// Replace both the placeholder and any paragraph-wrapped version
|
| 990 |
+
html = html.replace(new RegExp(`<p>${placeholderId}</p>`, 'g'), imageHtml);
|
| 991 |
+
html = html.replace(new RegExp(placeholderId, 'g'), imageHtml);
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
// Add result section inside the body
|
| 995 |
+
const body = widget.querySelector('.action-widget-body');
|
| 996 |
+
if (body) {
|
| 997 |
+
const resultSection = document.createElement('div');
|
| 998 |
+
resultSection.className = 'action-widget-result-section';
|
| 999 |
+
resultSection.innerHTML = `
|
| 1000 |
+
<div class="section-label" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-primary);">RESULT</div>
|
| 1001 |
+
<div class="section-content">${html}</div>
|
| 1002 |
+
`;
|
| 1003 |
+
body.appendChild(resultSection);
|
| 1004 |
+
}
|
| 1005 |
+
|
| 1006 |
+
// Update the tool response DOM element so getConversationHistory picks up actual results
|
| 1007 |
+
const toolCallId = toolCallIds[tabId];
|
| 1008 |
+
if (toolCallId) {
|
| 1009 |
+
// Find the hidden tool response element with this tool_call_id in the command center
|
| 1010 |
+
const commandContainer = document.getElementById('messages-command');
|
| 1011 |
+
if (commandContainer) {
|
| 1012 |
+
const toolMsgs = commandContainer.querySelectorAll('.message.tool[data-tool-response]');
|
| 1013 |
+
for (const toolMsg of toolMsgs) {
|
| 1014 |
+
try {
|
| 1015 |
+
const data = JSON.parse(toolMsg.getAttribute('data-tool-response'));
|
| 1016 |
+
if (data.tool_call_id === toolCallId) {
|
| 1017 |
+
data.content = resultContent;
|
| 1018 |
+
toolMsg.setAttribute('data-tool-response', JSON.stringify(data));
|
| 1019 |
+
break;
|
| 1020 |
+
}
|
| 1021 |
+
} catch (e) { /* ignore parse errors */ }
|
| 1022 |
+
}
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
+
// Also send to backend (non-blocking)
|
| 1026 |
+
apiFetch('/api/conversation/add-tool-response', {
|
| 1027 |
+
method: 'POST',
|
| 1028 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1029 |
+
body: JSON.stringify({
|
| 1030 |
+
tab_id: '0',
|
| 1031 |
+
tool_call_id: toolCallId,
|
| 1032 |
+
content: resultContent
|
| 1033 |
+
})
|
| 1034 |
+
}).catch(error => {
|
| 1035 |
+
console.error('Failed to update conversation history with result:', error);
|
| 1036 |
+
});
|
| 1037 |
+
}
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
function sendMessageToTab(tabId, message) {
|
| 1041 |
+
// Programmatically send a message to an existing agent tab
|
| 1042 |
+
const content = document.querySelector(`[data-content-id="${tabId}"]`);
|
| 1043 |
+
if (!content) return;
|
| 1044 |
+
|
| 1045 |
+
const input = content.querySelector('textarea') || content.querySelector('input[type="text"]');
|
| 1046 |
+
if (!input) return;
|
| 1047 |
+
|
| 1048 |
+
// Set the message and trigger send
|
| 1049 |
+
input.value = message;
|
| 1050 |
+
sendMessage(tabId);
|
| 1051 |
+
}
|
| 1052 |
+
|
| 1053 |
+
function handleActionToken(action, message, callback, taskId = null, parentTabId = null) {
|
| 1054 |
+
// Check if an agent with this task_id already exists
|
| 1055 |
+
if (taskId && taskIdToTabId[taskId]) {
|
| 1056 |
+
const existingTabId = taskIdToTabId[taskId];
|
| 1057 |
+
const existingContent = document.querySelector(`[data-content-id="${existingTabId}"]`);
|
| 1058 |
+
|
| 1059 |
+
if (existingContent) {
|
| 1060 |
+
// Send the message to the existing agent
|
| 1061 |
+
sendMessageToTab(existingTabId, message);
|
| 1062 |
+
if (callback) {
|
| 1063 |
+
callback(existingTabId);
|
| 1064 |
+
}
|
| 1065 |
+
return;
|
| 1066 |
+
} else {
|
| 1067 |
+
// Tab no longer exists, clean up the mapping
|
| 1068 |
+
delete taskIdToTabId[taskId];
|
| 1069 |
+
}
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
// Open the agent with the extracted message as initial prompt
|
| 1073 |
+
// Don't auto-switch to the new tab (autoSwitch = false)
|
| 1074 |
+
setTimeout(() => {
|
| 1075 |
+
const newTabId = createAgentTab(action, message, false, taskId, parentTabId);
|
| 1076 |
+
if (callback) {
|
| 1077 |
+
callback(newTabId);
|
| 1078 |
+
}
|
| 1079 |
+
}, 500);
|
| 1080 |
+
}
|
| 1081 |
+
|
| 1082 |
+
function setTabGenerating(tabId, isGenerating) {
|
| 1083 |
+
const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
|
| 1084 |
+
if (!tab) return;
|
| 1085 |
+
|
| 1086 |
+
const statusIndicator = tab.querySelector('.tab-status');
|
| 1087 |
+
if (!statusIndicator) return;
|
| 1088 |
+
|
| 1089 |
+
if (isGenerating) {
|
| 1090 |
+
statusIndicator.style.display = 'block';
|
| 1091 |
+
statusIndicator.classList.add('generating');
|
| 1092 |
+
} else {
|
| 1093 |
+
statusIndicator.classList.remove('generating');
|
| 1094 |
+
// Keep visible but stop animation
|
| 1095 |
+
setTimeout(() => {
|
| 1096 |
+
statusIndicator.style.display = 'none';
|
| 1097 |
+
}, 300);
|
| 1098 |
+
}
|
| 1099 |
+
|
| 1100 |
+
// Toggle SEND/STOP button
|
| 1101 |
+
const content = document.querySelector(`[data-content-id="${tabId}"]`);
|
| 1102 |
+
if (content) {
|
| 1103 |
+
const sendBtn = content.querySelector('.input-container button');
|
| 1104 |
+
if (sendBtn) {
|
| 1105 |
+
if (isGenerating) {
|
| 1106 |
+
sendBtn.textContent = 'STOP';
|
| 1107 |
+
sendBtn.classList.add('stop-btn');
|
| 1108 |
+
sendBtn.disabled = false; // Keep enabled so user can click STOP
|
| 1109 |
+
} else {
|
| 1110 |
+
sendBtn.textContent = 'SEND';
|
| 1111 |
+
sendBtn.classList.remove('stop-btn');
|
| 1112 |
+
}
|
| 1113 |
+
}
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
// Update timeline to reflect generating state
|
| 1117 |
+
setTimelineGenerating(tabId, isGenerating);
|
| 1118 |
+
|
| 1119 |
+
// Track when a pending agent launch actually starts generating
|
| 1120 |
+
if (isGenerating && pendingAgentLaunches > 0 && tabId !== 0 && timelineData[tabId]?.parentTabId === 0) {
|
| 1121 |
+
pendingAgentLaunches--;
|
| 1122 |
+
}
|
| 1123 |
+
|
| 1124 |
+
// When a child agent finishes and command center is blocked, check if all agents are done
|
| 1125 |
+
if (!isGenerating && commandInputBlocked && tabId !== 0) {
|
| 1126 |
+
const anyStillGenerating = Object.values(timelineData).some(
|
| 1127 |
+
td => td.parentTabId === 0 && td.isGenerating
|
| 1128 |
+
);
|
| 1129 |
+
if (!anyStillGenerating && pendingAgentLaunches === 0) {
|
| 1130 |
+
commandInputBlocked = false;
|
| 1131 |
+
setCommandCenterStopState(false);
|
| 1132 |
+
// Auto-continue: call command center again with agent results now in history
|
| 1133 |
+
continueCommandCenter();
|
| 1134 |
+
}
|
| 1135 |
+
}
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
|
| 1139 |
+
function cleanCodeOutput(text) {
|
| 1140 |
+
if (!text) return text;
|
| 1141 |
+
return text.split('\n')
|
| 1142 |
+
.filter(line => !line.match(/^\[Plot\/Image generated\]$/) && !line.match(/^\[Generated figures:.*\]$/))
|
| 1143 |
+
.join('\n')
|
| 1144 |
+
.trim();
|
| 1145 |
+
}
|
| 1146 |
+
|
| 1147 |
+
function showRetryIndicator(chatContainer, data) {
|
| 1148 |
+
// Remove existing retry indicator if present
|
| 1149 |
+
removeRetryIndicator(chatContainer);
|
| 1150 |
+
|
| 1151 |
+
const retryDiv = document.createElement('div');
|
| 1152 |
+
retryDiv.className = 'retry-indicator';
|
| 1153 |
+
retryDiv.innerHTML = `
|
| 1154 |
+
<div class="retry-content">
|
| 1155 |
+
<div class="retry-spinner"></div>
|
| 1156 |
+
<div class="retry-text">
|
| 1157 |
+
<div class="retry-message">${escapeHtml(data.message)}</div>
|
| 1158 |
+
<div class="retry-status">Retrying (${data.attempt}/${data.max_attempts}) in ${data.delay}s...</div>
|
| 1159 |
+
</div>
|
| 1160 |
+
</div>
|
| 1161 |
+
`;
|
| 1162 |
+
chatContainer.appendChild(retryDiv);
|
| 1163 |
+
scrollChatToBottom(chatContainer);
|
| 1164 |
+
|
| 1165 |
+
// Start countdown
|
| 1166 |
+
let remaining = data.delay;
|
| 1167 |
+
const statusEl = retryDiv.querySelector('.retry-status');
|
| 1168 |
+
const countdownInterval = setInterval(() => {
|
| 1169 |
+
remaining--;
|
| 1170 |
+
if (remaining > 0 && statusEl) {
|
| 1171 |
+
statusEl.textContent = `Retrying (${data.attempt}/${data.max_attempts}) in ${remaining}s...`;
|
| 1172 |
+
} else {
|
| 1173 |
+
clearInterval(countdownInterval);
|
| 1174 |
+
if (statusEl) {
|
| 1175 |
+
statusEl.textContent = `Retrying (${data.attempt}/${data.max_attempts})...`;
|
| 1176 |
+
}
|
| 1177 |
+
}
|
| 1178 |
+
}, 1000);
|
| 1179 |
+
|
| 1180 |
+
// Store interval ID for cleanup
|
| 1181 |
+
retryDiv.dataset.countdownInterval = countdownInterval;
|
| 1182 |
+
}
|
| 1183 |
+
|
| 1184 |
+
function removeRetryIndicator(chatContainer) {
|
| 1185 |
+
const existing = chatContainer.querySelector('.retry-indicator');
|
| 1186 |
+
if (existing) {
|
| 1187 |
+
// Clear countdown interval if exists
|
| 1188 |
+
if (existing.dataset.countdownInterval) {
|
| 1189 |
+
clearInterval(parseInt(existing.dataset.countdownInterval));
|
| 1190 |
+
}
|
| 1191 |
+
existing.remove();
|
| 1192 |
+
}
|
| 1193 |
+
}
|
| 1194 |
+
|
| 1195 |
+
// Configure marked options once
|
| 1196 |
+
if (typeof marked !== 'undefined') {
|
| 1197 |
+
// Custom renderer to add target="_blank" to links and syntax highlighting
|
| 1198 |
+
const renderer = new marked.Renderer();
|
| 1199 |
+
|
| 1200 |
+
// Handle both old API (separate args) and new API (token object)
|
| 1201 |
+
renderer.link = function(hrefOrToken, title, text) {
|
| 1202 |
+
const href = typeof hrefOrToken === 'object' ? hrefOrToken.href : hrefOrToken;
|
| 1203 |
+
const titleVal = typeof hrefOrToken === 'object' ? hrefOrToken.title : title;
|
| 1204 |
+
const textVal = typeof hrefOrToken === 'object' ? hrefOrToken.text : text;
|
| 1205 |
+
const titleAttr = titleVal ? ` title="${titleVal}"` : '';
|
| 1206 |
+
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${textVal}</a>`;
|
| 1207 |
+
};
|
| 1208 |
+
|
| 1209 |
+
renderer.code = function(codeOrToken, language) {
|
| 1210 |
+
// Handle both old API (separate args) and new API (token object)
|
| 1211 |
+
const code = typeof codeOrToken === 'object' ? codeOrToken.text : codeOrToken;
|
| 1212 |
+
const lang = typeof codeOrToken === 'object' ? codeOrToken.lang : language;
|
| 1213 |
+
|
| 1214 |
+
// Use Prism for syntax highlighting if available
|
| 1215 |
+
if (typeof Prism !== 'undefined' && lang && Prism.languages[lang]) {
|
| 1216 |
+
const highlighted = Prism.highlight(code, Prism.languages[lang], lang);
|
| 1217 |
+
return `<pre><code class="language-${lang}">${highlighted}</code></pre>`;
|
| 1218 |
+
}
|
| 1219 |
+
return `<pre><code>${escapeHtml(code)}</code></pre>`;
|
| 1220 |
+
};
|
| 1221 |
+
|
| 1222 |
+
marked.setOptions({
|
| 1223 |
+
gfm: true, // GitHub Flavored Markdown
|
| 1224 |
+
breaks: false, // Don't convert \n to <br>
|
| 1225 |
+
pedantic: false,
|
| 1226 |
+
renderer: renderer
|
| 1227 |
+
});
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
// Resolve <figure_N> and <image_N> references using the global registry
|
| 1231 |
+
function resolveGlobalFigureRefs(html) {
|
| 1232 |
+
return html.replace(/<\/?(figure_\d+|image_\d+)>/gi, (match) => {
|
| 1233 |
+
// Extract the name (strip < > and /)
|
| 1234 |
+
const name = match.replace(/[<>/]/g, '');
|
| 1235 |
+
const data = globalFigureRegistry[name];
|
| 1236 |
+
if (!data) return match; // Leave unresolved refs as-is
|
| 1237 |
+
if (data.type === 'png' || data.type === 'jpeg') {
|
| 1238 |
+
return `<img src="data:image/${data.type};base64,${data.data}" style="max-width: 400px; max-height: 400px; height: auto; border-radius: 4px; margin: 12px 0; display: block;" onclick="openImageModal(this.src)">`;
|
| 1239 |
+
} else if (data.type === 'svg') {
|
| 1240 |
+
return `<div style="margin: 12px 0;">${atob(data.data)}</div>`;
|
| 1241 |
+
}
|
| 1242 |
+
return match;
|
| 1243 |
+
});
|
| 1244 |
+
}
|
| 1245 |
+
|
| 1246 |
+
function parseMarkdown(text) {
|
| 1247 |
+
// Use marked library for proper markdown parsing
|
| 1248 |
+
let html;
|
| 1249 |
+
if (typeof marked !== 'undefined') {
|
| 1250 |
+
html = marked.parse(text);
|
| 1251 |
+
} else {
|
| 1252 |
+
// Fallback: just escape HTML and convert newlines to paragraphs
|
| 1253 |
+
html = `<p>${escapeHtml(text).replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>')}</p>`;
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
// Render LaTeX with KaTeX if available
|
| 1257 |
+
if (typeof katex !== 'undefined') {
|
| 1258 |
+
// Block math: $$ ... $$ (must handle newlines)
|
| 1259 |
+
html = html.replace(/\$\$([\s\S]*?)\$\$/g, (match, latex) => {
|
| 1260 |
+
try {
|
| 1261 |
+
return katex.renderToString(latex.trim(), { displayMode: true, throwOnError: false });
|
| 1262 |
+
} catch (e) {
|
| 1263 |
+
return `<span class="katex-error">${escapeHtml(match)}</span>`;
|
| 1264 |
+
}
|
| 1265 |
+
});
|
| 1266 |
+
|
| 1267 |
+
// Inline math: $ ... $ (but not $$ or escaped \$)
|
| 1268 |
+
html = html.replace(/(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/g, (match, latex) => {
|
| 1269 |
+
try {
|
| 1270 |
+
return katex.renderToString(latex.trim(), { displayMode: false, throwOnError: false });
|
| 1271 |
+
} catch (e) {
|
| 1272 |
+
return `<span class="katex-error">${escapeHtml(match)}</span>`;
|
| 1273 |
+
}
|
| 1274 |
+
});
|
| 1275 |
+
}
|
| 1276 |
+
|
| 1277 |
+
return html;
|
| 1278 |
+
}
|
| 1279 |
+
|
|
@@ -0,0 +1,573 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function createAgentTab(type, initialMessage = null, autoSwitch = true, taskId = null, parentTabId = null) {
|
| 2 |
+
const tabId = tabCounter++;
|
| 3 |
+
|
| 4 |
+
// Use task_id if provided, otherwise generate default title
|
| 5 |
+
let title;
|
| 6 |
+
if (taskId) {
|
| 7 |
+
// Convert dashes to spaces and title case for display
|
| 8 |
+
title = taskId;
|
| 9 |
+
// Register this agent for task_id reuse
|
| 10 |
+
taskIdToTabId[taskId] = tabId;
|
| 11 |
+
} else if (type !== 'command-center') {
|
| 12 |
+
agentCounters[type]++;
|
| 13 |
+
title = `New ${type} task`;
|
| 14 |
+
} else {
|
| 15 |
+
title = getTypeLabel(type);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// Register in timeline
|
| 19 |
+
registerAgentInTimeline(tabId, type, title, parentTabId);
|
| 20 |
+
|
| 21 |
+
// Create tab
|
| 22 |
+
const tab = document.createElement('div');
|
| 23 |
+
tab.className = 'tab';
|
| 24 |
+
tab.dataset.tabId = tabId;
|
| 25 |
+
tab.innerHTML = `
|
| 26 |
+
<span class="tab-title">${title}</span>
|
| 27 |
+
<span class="tab-status" style="display: none;"><span></span><span></span><span></span></span>
|
| 28 |
+
<span class="tab-close">Γ</span>
|
| 29 |
+
`;
|
| 30 |
+
|
| 31 |
+
// Insert into the dynamic tabs container
|
| 32 |
+
const dynamicTabs = document.getElementById('dynamicTabs');
|
| 33 |
+
dynamicTabs.appendChild(tab);
|
| 34 |
+
|
| 35 |
+
// Hide tab for subagents until the user clicks on them
|
| 36 |
+
if (!autoSwitch && parentTabId !== null) {
|
| 37 |
+
tab.style.display = 'none';
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Create content
|
| 41 |
+
const content = document.createElement('div');
|
| 42 |
+
content.className = 'tab-content';
|
| 43 |
+
content.dataset.contentId = tabId;
|
| 44 |
+
content.innerHTML = createAgentContent(type, tabId, title);
|
| 45 |
+
|
| 46 |
+
document.querySelector('.main-content').appendChild(content);
|
| 47 |
+
|
| 48 |
+
// Only switch to new tab if autoSwitch is true
|
| 49 |
+
if (autoSwitch) {
|
| 50 |
+
switchToTab(tabId);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Add event listeners for the new content
|
| 54 |
+
if (type !== 'command-center') {
|
| 55 |
+
setupInputListeners(content, tabId);
|
| 56 |
+
const input = content.querySelector('textarea');
|
| 57 |
+
|
| 58 |
+
// If this is a code agent, start the sandbox proactively
|
| 59 |
+
if (type === 'code') {
|
| 60 |
+
startSandbox(tabId);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// If there's an initial message, automatically send it
|
| 64 |
+
if (initialMessage && input) {
|
| 65 |
+
input.value = initialMessage;
|
| 66 |
+
// Small delay to ensure everything is set up
|
| 67 |
+
setTimeout(() => {
|
| 68 |
+
sendMessage(tabId);
|
| 69 |
+
}, 100);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Save workspace state after creating new tab
|
| 74 |
+
saveWorkspaceDebounced();
|
| 75 |
+
|
| 76 |
+
return tabId; // Return the tabId so we can reference it
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function createAgentContent(type, tabId, title = null) {
|
| 80 |
+
if (type === 'command-center') {
|
| 81 |
+
return document.querySelector('[data-content-id="0"]').innerHTML;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Use unique ID combining type and tabId to ensure unique container IDs
|
| 85 |
+
const uniqueId = `${type}-${tabId}`;
|
| 86 |
+
|
| 87 |
+
// Display title or default
|
| 88 |
+
const displayTitle = title || `New ${type} task`;
|
| 89 |
+
|
| 90 |
+
return `
|
| 91 |
+
<div class="agent-interface">
|
| 92 |
+
<div class="agent-header">
|
| 93 |
+
<div>
|
| 94 |
+
<div class="agent-type">${getTypeLabel(type)}</div>
|
| 95 |
+
<h2>${escapeHtml(displayTitle)}</h2>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
<div class="agent-body">
|
| 99 |
+
<div class="chat-container" id="messages-${uniqueId}" data-agent-type="${type}">
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
<div class="input-area">
|
| 103 |
+
<div class="input-container">
|
| 104 |
+
<textarea placeholder="${getPlaceholder(type)}" id="input-${uniqueId}" rows="1"></textarea>
|
| 105 |
+
<button>SEND</button>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
`;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function switchToTab(tabId) {
|
| 113 |
+
// Deactivate all tabs
|
| 114 |
+
document.querySelectorAll('.tab').forEach(tab => {
|
| 115 |
+
tab.classList.remove('active');
|
| 116 |
+
});
|
| 117 |
+
document.querySelectorAll('.tab-content').forEach(content => {
|
| 118 |
+
content.classList.remove('active');
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
// Activate selected tab (use .tab selector to avoid matching timeline elements)
|
| 122 |
+
const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
|
| 123 |
+
const content = document.querySelector(`.tab-content[data-content-id="${tabId}"]`);
|
| 124 |
+
|
| 125 |
+
if (content) {
|
| 126 |
+
// For settings, there's no tab, just show the content
|
| 127 |
+
if (tabId === 'settings') {
|
| 128 |
+
content.classList.add('active');
|
| 129 |
+
activeTabId = tabId;
|
| 130 |
+
} else if (tab) {
|
| 131 |
+
tab.style.display = '';
|
| 132 |
+
tab.classList.add('active');
|
| 133 |
+
content.classList.add('active');
|
| 134 |
+
activeTabId = tabId;
|
| 135 |
+
|
| 136 |
+
// Save workspace state after switching tabs
|
| 137 |
+
saveWorkspaceDebounced();
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// Update timeline to reflect active tab
|
| 142 |
+
renderTimeline();
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
function closeTab(tabId) {
|
| 146 |
+
if (tabId === 0) return; // Can't close command center
|
| 147 |
+
|
| 148 |
+
const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
|
| 149 |
+
const content = document.querySelector(`[data-content-id="${tabId}"]`);
|
| 150 |
+
|
| 151 |
+
if (tab && content) {
|
| 152 |
+
// Check if this is a code agent and stop its sandbox
|
| 153 |
+
const chatContainer = content.querySelector('.chat-container');
|
| 154 |
+
const agentType = chatContainer?.dataset.agentType || 'chat';
|
| 155 |
+
|
| 156 |
+
if (agentType === 'code') {
|
| 157 |
+
stopSandbox(tabId);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Save the tab's content BEFORE removing from DOM so we can restore it later
|
| 161 |
+
if (timelineData[tabId]) {
|
| 162 |
+
timelineData[tabId].savedContent = content.innerHTML;
|
| 163 |
+
timelineData[tabId].savedTitle = tab.querySelector('.tab-title')?.textContent;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// Clean up task_id mapping
|
| 167 |
+
for (const [taskId, mappedTabId] of Object.entries(taskIdToTabId)) {
|
| 168 |
+
if (mappedTabId === tabId) {
|
| 169 |
+
delete taskIdToTabId[taskId];
|
| 170 |
+
break;
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
tab.remove();
|
| 175 |
+
content.remove();
|
| 176 |
+
|
| 177 |
+
// Mark as closed in timeline (don't remove - allow reopening)
|
| 178 |
+
if (timelineData[tabId]) {
|
| 179 |
+
timelineData[tabId].isClosed = true;
|
| 180 |
+
renderTimeline();
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// Switch to command center if closing active tab
|
| 184 |
+
if (activeTabId === tabId) {
|
| 185 |
+
switchToTab(0);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
// Save workspace state after closing tab
|
| 189 |
+
saveWorkspaceDebounced();
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function showProgressWidget(chatContainer) {
|
| 194 |
+
// Remove any existing progress widget
|
| 195 |
+
hideProgressWidget(chatContainer);
|
| 196 |
+
|
| 197 |
+
const widget = document.createElement('div');
|
| 198 |
+
widget.className = 'progress-widget';
|
| 199 |
+
widget.innerHTML = `
|
| 200 |
+
<div class="progress-spinner">
|
| 201 |
+
<span></span>
|
| 202 |
+
<span></span>
|
| 203 |
+
<span></span>
|
| 204 |
+
</div>
|
| 205 |
+
<span class="progress-text">Generating...</span>
|
| 206 |
+
`;
|
| 207 |
+
chatContainer.appendChild(widget);
|
| 208 |
+
scrollChatToBottom(chatContainer, true);
|
| 209 |
+
return widget;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
function hideProgressWidget(chatContainer) {
|
| 213 |
+
const widget = chatContainer.querySelector('.progress-widget');
|
| 214 |
+
if (widget) {
|
| 215 |
+
widget.remove();
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function scrollChatToBottom(chatContainer, force = false) {
|
| 220 |
+
// The actual scrolling container is .agent-body
|
| 221 |
+
const agentBody = chatContainer.closest('.agent-body');
|
| 222 |
+
if (!agentBody) return;
|
| 223 |
+
|
| 224 |
+
// If not forced, only scroll if user is already near the bottom
|
| 225 |
+
if (!force) {
|
| 226 |
+
const distanceFromBottom = agentBody.scrollHeight - agentBody.scrollTop - agentBody.clientHeight;
|
| 227 |
+
if (distanceFromBottom > 150) return;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
agentBody.scrollTop = agentBody.scrollHeight;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
function scrollToTimelineEvent(tabId, eventIndex) {
|
| 234 |
+
if (eventIndex === undefined || eventIndex === null) return;
|
| 235 |
+
|
| 236 |
+
// Find the tab's content area and chat container
|
| 237 |
+
const content = document.querySelector(`[data-content-id="${tabId}"]`);
|
| 238 |
+
if (!content) return;
|
| 239 |
+
const chatContainer = content.querySelector('.chat-container');
|
| 240 |
+
if (!chatContainer) return;
|
| 241 |
+
|
| 242 |
+
// Find the chat element tagged with this timeline index
|
| 243 |
+
const target = chatContainer.querySelector(`[data-timeline-index="${eventIndex}"]`);
|
| 244 |
+
if (!target) return;
|
| 245 |
+
|
| 246 |
+
// Scroll the agent-body so the target is near the top
|
| 247 |
+
const agentBody = chatContainer.closest('.agent-body');
|
| 248 |
+
if (!agentBody) return;
|
| 249 |
+
|
| 250 |
+
// Use a small delay to ensure tab switch has rendered
|
| 251 |
+
requestAnimationFrame(() => {
|
| 252 |
+
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 253 |
+
});
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
async function abortAgent(tabId) {
|
| 257 |
+
// Abort the frontend fetch
|
| 258 |
+
const controller = activeAbortControllers[tabId];
|
| 259 |
+
if (controller) {
|
| 260 |
+
controller.abort();
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// For command center (tab 0): also abort all generating child tabs
|
| 264 |
+
if (tabId === 0) {
|
| 265 |
+
for (const [childId, td] of Object.entries(timelineData)) {
|
| 266 |
+
if (td.parentTabId === 0 && td.isGenerating) {
|
| 267 |
+
// Abort frontend fetch
|
| 268 |
+
const childController = activeAbortControllers[childId];
|
| 269 |
+
if (childController) {
|
| 270 |
+
childController.abort();
|
| 271 |
+
}
|
| 272 |
+
// Abort backend agent
|
| 273 |
+
try {
|
| 274 |
+
await apiFetch('/api/abort', {
|
| 275 |
+
method: 'POST',
|
| 276 |
+
headers: { 'Content-Type': 'application/json' },
|
| 277 |
+
body: JSON.stringify({ agent_id: childId.toString() })
|
| 278 |
+
});
|
| 279 |
+
} catch (e) { /* ignore */ }
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
// Unblock command center input and restore SEND button
|
| 283 |
+
commandInputBlocked = false;
|
| 284 |
+
pendingAgentLaunches = 0;
|
| 285 |
+
setCommandCenterStopState(false);
|
| 286 |
+
const commandInput = document.getElementById('input-command');
|
| 287 |
+
if (commandInput) {
|
| 288 |
+
commandInput.disabled = false;
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
// Tell the backend to set the abort flag for this agent
|
| 293 |
+
try {
|
| 294 |
+
await apiFetch('/api/abort', {
|
| 295 |
+
method: 'POST',
|
| 296 |
+
headers: { 'Content-Type': 'application/json' },
|
| 297 |
+
body: JSON.stringify({ agent_id: tabId.toString() })
|
| 298 |
+
});
|
| 299 |
+
} catch (e) {
|
| 300 |
+
// Ignore abort endpoint errors
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
async function sendMessage(tabId) {
|
| 305 |
+
// If tab is currently generating, abort instead of sending
|
| 306 |
+
// For command center: also abort if input is blocked (sub-agents still running)
|
| 307 |
+
if (timelineData[tabId]?.isGenerating || (tabId === 0 && commandInputBlocked)) {
|
| 308 |
+
abortAgent(tabId);
|
| 309 |
+
return;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
const content = document.querySelector(`[data-content-id="${tabId}"]`);
|
| 313 |
+
if (!content) return;
|
| 314 |
+
|
| 315 |
+
// Support both textarea and input
|
| 316 |
+
const input = content.querySelector('textarea') || content.querySelector('input[type="text"]');
|
| 317 |
+
const chatContainer = content.querySelector('.chat-container');
|
| 318 |
+
|
| 319 |
+
if (!input || !chatContainer) return;
|
| 320 |
+
|
| 321 |
+
const message = input.value.trim();
|
| 322 |
+
if (!message) return;
|
| 323 |
+
|
| 324 |
+
// Remove welcome message if it exists (only on first user message)
|
| 325 |
+
const welcomeMsg = chatContainer.querySelector('.welcome-message');
|
| 326 |
+
const isFirstMessage = welcomeMsg !== null;
|
| 327 |
+
if (welcomeMsg) {
|
| 328 |
+
welcomeMsg.remove();
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
// Add user message
|
| 332 |
+
const userMsg = document.createElement('div');
|
| 333 |
+
userMsg.className = 'message user';
|
| 334 |
+
userMsg.innerHTML = `<div class="message-content">${parseMarkdown(message.trim())}</div>`;
|
| 335 |
+
linkifyFilePaths(userMsg);
|
| 336 |
+
chatContainer.appendChild(userMsg);
|
| 337 |
+
|
| 338 |
+
// Add to timeline and tag DOM element for scroll-to-turn
|
| 339 |
+
const evIdx = addTimelineEvent(tabId, 'user', message);
|
| 340 |
+
userMsg.dataset.timelineIndex = evIdx;
|
| 341 |
+
|
| 342 |
+
// Scroll the agent body (the actual scrolling container) to bottom
|
| 343 |
+
const agentBody = chatContainer.closest('.agent-body');
|
| 344 |
+
if (agentBody) {
|
| 345 |
+
agentBody.scrollTop = agentBody.scrollHeight;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
// Show progress widget while waiting for response
|
| 349 |
+
showProgressWidget(chatContainer);
|
| 350 |
+
|
| 351 |
+
// Generate a title for the agent if this is the first message and not command center
|
| 352 |
+
if (isFirstMessage && tabId !== 0) {
|
| 353 |
+
generateAgentTitle(tabId, message);
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
// Clear input and disable it during processing
|
| 357 |
+
input.value = '';
|
| 358 |
+
input.style.height = 'auto'; // Reset textarea height
|
| 359 |
+
input.disabled = true;
|
| 360 |
+
|
| 361 |
+
// Set tab to generating state
|
| 362 |
+
setTabGenerating(tabId, true);
|
| 363 |
+
|
| 364 |
+
// Determine agent type from chat container ID
|
| 365 |
+
const agentType = getAgentTypeFromContainer(chatContainer);
|
| 366 |
+
|
| 367 |
+
// Send full conversation history for all agent types (stateless backend)
|
| 368 |
+
const messages = getConversationHistory(chatContainer);
|
| 369 |
+
|
| 370 |
+
// Stream response from backend
|
| 371 |
+
await streamChatResponse(messages, chatContainer, agentType, tabId);
|
| 372 |
+
|
| 373 |
+
// Re-enable input and mark generation as complete
|
| 374 |
+
setTabGenerating(tabId, false);
|
| 375 |
+
|
| 376 |
+
if (tabId === 0) {
|
| 377 |
+
// Command center: keep input blocked if launched agents are still running or pending
|
| 378 |
+
const anyChildGenerating = Object.values(timelineData).some(
|
| 379 |
+
td => td.parentTabId === 0 && td.isGenerating
|
| 380 |
+
);
|
| 381 |
+
if (anyChildGenerating || pendingAgentLaunches > 0) {
|
| 382 |
+
commandInputBlocked = true;
|
| 383 |
+
// Keep STOP button visible and input disabled while children run
|
| 384 |
+
input.disabled = true;
|
| 385 |
+
setCommandCenterStopState(true);
|
| 386 |
+
} else {
|
| 387 |
+
input.disabled = false;
|
| 388 |
+
input.focus();
|
| 389 |
+
}
|
| 390 |
+
} else {
|
| 391 |
+
input.disabled = false;
|
| 392 |
+
input.focus();
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
// Save workspace state after message exchange completes
|
| 396 |
+
saveWorkspaceDebounced();
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
function setCommandCenterStopState(showStop) {
|
| 400 |
+
const content = document.querySelector('[data-content-id="0"]');
|
| 401 |
+
if (!content) return;
|
| 402 |
+
const sendBtn = content.querySelector('.input-container button');
|
| 403 |
+
if (!sendBtn) return;
|
| 404 |
+
if (showStop) {
|
| 405 |
+
sendBtn.textContent = 'STOP';
|
| 406 |
+
sendBtn.classList.add('stop-btn');
|
| 407 |
+
sendBtn.disabled = false;
|
| 408 |
+
} else {
|
| 409 |
+
sendBtn.textContent = 'SEND';
|
| 410 |
+
sendBtn.classList.remove('stop-btn');
|
| 411 |
+
}
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
async function continueCommandCenter() {
|
| 415 |
+
// Called when all launched agents finish β re-run command center with actual results in history
|
| 416 |
+
const chatContainer = document.getElementById('messages-command');
|
| 417 |
+
const commandInput = document.getElementById('input-command');
|
| 418 |
+
if (!chatContainer) return;
|
| 419 |
+
|
| 420 |
+
setTabGenerating(0, true);
|
| 421 |
+
|
| 422 |
+
const messages = getConversationHistory(chatContainer);
|
| 423 |
+
await streamChatResponse(messages, chatContainer, 'command', 0);
|
| 424 |
+
|
| 425 |
+
setTabGenerating(0, false);
|
| 426 |
+
|
| 427 |
+
// Check if new agents were launched (recursive blocking)
|
| 428 |
+
const anyChildGenerating = Object.values(timelineData).some(
|
| 429 |
+
td => td.parentTabId === 0 && td.isGenerating
|
| 430 |
+
);
|
| 431 |
+
if (anyChildGenerating || pendingAgentLaunches > 0) {
|
| 432 |
+
commandInputBlocked = true;
|
| 433 |
+
if (commandInput) commandInput.disabled = true;
|
| 434 |
+
setCommandCenterStopState(true);
|
| 435 |
+
} else if (commandInput) {
|
| 436 |
+
commandInput.disabled = false;
|
| 437 |
+
commandInput.focus();
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
saveWorkspaceDebounced();
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
async function generateAgentTitle(tabId, query) {
|
| 444 |
+
const currentSettings = getSettings();
|
| 445 |
+
const backendEndpoint = '/api';
|
| 446 |
+
const llmEndpoint = currentSettings.endpoint || 'https://api.openai.com/v1';
|
| 447 |
+
const modelToUse = currentSettings.model || 'gpt-4';
|
| 448 |
+
|
| 449 |
+
try {
|
| 450 |
+
const response = await apiFetch(`${backendEndpoint}/generate-title`, {
|
| 451 |
+
method: 'POST',
|
| 452 |
+
headers: { 'Content-Type': 'application/json' },
|
| 453 |
+
body: JSON.stringify({
|
| 454 |
+
query: query,
|
| 455 |
+
endpoint: llmEndpoint,
|
| 456 |
+
token: currentSettings.token || null,
|
| 457 |
+
model: modelToUse
|
| 458 |
+
})
|
| 459 |
+
});
|
| 460 |
+
|
| 461 |
+
if (response.ok) {
|
| 462 |
+
const result = await response.json();
|
| 463 |
+
const title = result.title;
|
| 464 |
+
|
| 465 |
+
// Update the tab title
|
| 466 |
+
const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
|
| 467 |
+
if (tab) {
|
| 468 |
+
const titleEl = tab.querySelector('.tab-title');
|
| 469 |
+
if (titleEl) {
|
| 470 |
+
titleEl.textContent = title.toUpperCase();
|
| 471 |
+
// Save workspace after title update
|
| 472 |
+
saveWorkspaceDebounced();
|
| 473 |
+
// Update timeline to reflect new title
|
| 474 |
+
updateTimelineTitle(tabId, title.toUpperCase());
|
| 475 |
+
}
|
| 476 |
+
}
|
| 477 |
+
}
|
| 478 |
+
} catch (error) {
|
| 479 |
+
console.error('Failed to generate title:', error);
|
| 480 |
+
// Don't show error to user, just keep the default title
|
| 481 |
+
}
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
function getAgentTypeFromContainer(chatContainer) {
|
| 485 |
+
// Try to get type from data attribute first (for dynamically created agents)
|
| 486 |
+
const typeFromData = chatContainer.dataset.agentType;
|
| 487 |
+
if (typeFromData) {
|
| 488 |
+
return typeFromData;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
// Fallback: Extract agent type from the container ID (e.g., "messages-command" -> "command")
|
| 492 |
+
const containerId = chatContainer.id;
|
| 493 |
+
if (containerId && containerId.startsWith('messages-')) {
|
| 494 |
+
const type = containerId.replace('messages-', '');
|
| 495 |
+
// Map to agent type
|
| 496 |
+
if (type === 'command') return 'command';
|
| 497 |
+
if (type.startsWith('agent')) return 'agent';
|
| 498 |
+
if (type.startsWith('code')) return 'code';
|
| 499 |
+
if (type.startsWith('research')) return 'research';
|
| 500 |
+
if (type.startsWith('chat')) return 'chat';
|
| 501 |
+
}
|
| 502 |
+
return 'command'; // Default fallback
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
function getConversationHistory(chatContainer) {
|
| 506 |
+
const messages = [];
|
| 507 |
+
const messageElements = chatContainer.querySelectorAll('.message');
|
| 508 |
+
|
| 509 |
+
messageElements.forEach(msg => {
|
| 510 |
+
if (msg.classList.contains('user')) {
|
| 511 |
+
// User messages use .message-content
|
| 512 |
+
const contentEl = msg.querySelector('.message-content');
|
| 513 |
+
const content = contentEl?.textContent.trim() || msg.textContent.trim();
|
| 514 |
+
if (content) {
|
| 515 |
+
messages.push({ role: 'user', content: content });
|
| 516 |
+
}
|
| 517 |
+
} else if (msg.classList.contains('assistant')) {
|
| 518 |
+
// Assistant messages use .message-content
|
| 519 |
+
const contentEl = msg.querySelector('.message-content');
|
| 520 |
+
const content = contentEl?.textContent.trim();
|
| 521 |
+
|
| 522 |
+
// Check if this message has a tool call
|
| 523 |
+
const toolCallData = msg.getAttribute('data-tool-call');
|
| 524 |
+
if (toolCallData) {
|
| 525 |
+
const toolCall = JSON.parse(toolCallData);
|
| 526 |
+
let funcName, funcArgs;
|
| 527 |
+
|
| 528 |
+
if (toolCall.function_name) {
|
| 529 |
+
// Agent-style tool call (web_search, read_url, etc.)
|
| 530 |
+
funcName = toolCall.function_name;
|
| 531 |
+
funcArgs = toolCall.arguments;
|
| 532 |
+
} else {
|
| 533 |
+
// Command center-style tool call (launch_*_agent)
|
| 534 |
+
funcName = `launch_${toolCall.agent_type}_agent`;
|
| 535 |
+
funcArgs = JSON.stringify({
|
| 536 |
+
task: toolCall.message,
|
| 537 |
+
topic: toolCall.message,
|
| 538 |
+
message: toolCall.message
|
| 539 |
+
});
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
messages.push({
|
| 543 |
+
role: 'assistant',
|
| 544 |
+
content: toolCall.thinking || content || '',
|
| 545 |
+
tool_calls: [{
|
| 546 |
+
id: toolCall.tool_call_id || 'tool_' + Date.now(),
|
| 547 |
+
type: 'function',
|
| 548 |
+
function: {
|
| 549 |
+
name: funcName,
|
| 550 |
+
arguments: funcArgs
|
| 551 |
+
}
|
| 552 |
+
}]
|
| 553 |
+
});
|
| 554 |
+
} else if (content && !content.includes('msg-') && !content.includes('β Launched')) {
|
| 555 |
+
// Regular assistant message (exclude launch notifications)
|
| 556 |
+
messages.push({ role: 'assistant', content: content });
|
| 557 |
+
}
|
| 558 |
+
} else if (msg.classList.contains('tool')) {
|
| 559 |
+
// Tool response message
|
| 560 |
+
const toolResponseData = msg.getAttribute('data-tool-response');
|
| 561 |
+
if (toolResponseData) {
|
| 562 |
+
const toolResponse = JSON.parse(toolResponseData);
|
| 563 |
+
messages.push({
|
| 564 |
+
role: 'tool',
|
| 565 |
+
tool_call_id: toolResponse.tool_call_id,
|
| 566 |
+
content: toolResponse.content
|
| 567 |
+
});
|
| 568 |
+
}
|
| 569 |
+
}
|
| 570 |
+
});
|
| 571 |
+
|
| 572 |
+
return messages;
|
| 573 |
+
}
|
|
@@ -0,0 +1,519 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Add event to timeline
|
| 2 |
+
function addTimelineEvent(tabId, eventType, content, childTabId = null, meta = null) {
|
| 3 |
+
if (!timelineData[tabId]) {
|
| 4 |
+
timelineData[tabId] = { type: 'unknown', title: 'Unknown', events: [], parentTabId: null, isGenerating: false };
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
// Truncate content for preview (first 80 chars)
|
| 8 |
+
const preview = content.length > 80 ? content.substring(0, 80) + '...' : content;
|
| 9 |
+
|
| 10 |
+
const eventIndex = timelineData[tabId].events.length;
|
| 11 |
+
timelineData[tabId].events.push({
|
| 12 |
+
type: eventType, // 'user', 'assistant', or 'agent'
|
| 13 |
+
content: preview,
|
| 14 |
+
childTabId: childTabId,
|
| 15 |
+
meta: meta, // optional: { tag: 'SEARCH' } for tool-like entries
|
| 16 |
+
timestamp: Date.now(),
|
| 17 |
+
index: eventIndex,
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
renderTimeline();
|
| 21 |
+
return eventIndex;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Register a new agent in timeline
|
| 25 |
+
function registerAgentInTimeline(tabId, type, title, parentTabId = null) {
|
| 26 |
+
timelineData[tabId] = {
|
| 27 |
+
type: type,
|
| 28 |
+
title: title,
|
| 29 |
+
events: [],
|
| 30 |
+
parentTabId: parentTabId,
|
| 31 |
+
isGenerating: false
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
// If this agent was launched from another, add an agent event to parent
|
| 35 |
+
if (parentTabId !== null && timelineData[parentTabId]) {
|
| 36 |
+
addTimelineEvent(parentTabId, 'agent', title, tabId);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
renderTimeline();
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Update generating state
|
| 43 |
+
function setTimelineGenerating(tabId, isGenerating) {
|
| 44 |
+
if (timelineData[tabId]) {
|
| 45 |
+
timelineData[tabId].isGenerating = isGenerating;
|
| 46 |
+
renderTimeline();
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Update agent title in timeline
|
| 51 |
+
function updateTimelineTitle(tabId, title) {
|
| 52 |
+
if (timelineData[tabId]) {
|
| 53 |
+
timelineData[tabId].title = title;
|
| 54 |
+
renderTimeline();
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// Remove agent from timeline
|
| 59 |
+
function removeFromTimeline(tabId) {
|
| 60 |
+
// Remove from parent's events if it was a child
|
| 61 |
+
const notebook = timelineData[tabId];
|
| 62 |
+
if (notebook && notebook.parentTabId !== null) {
|
| 63 |
+
const parent = timelineData[notebook.parentTabId];
|
| 64 |
+
if (parent) {
|
| 65 |
+
parent.events = parent.events.filter(e => e.childTabId !== tabId);
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
delete timelineData[tabId];
|
| 69 |
+
renderTimeline();
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Open a closed tab or switch to an existing one
|
| 73 |
+
function openOrSwitchToTab(tabId) {
|
| 74 |
+
// Check for actual tab element (not timeline elements which also have data-tab-id)
|
| 75 |
+
const existingTab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
|
| 76 |
+
if (existingTab) {
|
| 77 |
+
// Tab exists, just switch to it
|
| 78 |
+
switchToTab(tabId);
|
| 79 |
+
} else {
|
| 80 |
+
// Tab was closed, need to recreate it with the SAME tabId
|
| 81 |
+
const notebook = timelineData[tabId];
|
| 82 |
+
if (notebook) {
|
| 83 |
+
reopenClosedTab(tabId, notebook);
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Recreate a closed tab using its existing tabId (doesn't create new timeline entry)
|
| 89 |
+
function reopenClosedTab(tabId, notebook) {
|
| 90 |
+
const type = notebook.type;
|
| 91 |
+
const title = notebook.savedTitle || notebook.title;
|
| 92 |
+
|
| 93 |
+
// Create tab element
|
| 94 |
+
const tab = document.createElement('div');
|
| 95 |
+
tab.className = 'tab';
|
| 96 |
+
tab.dataset.tabId = tabId;
|
| 97 |
+
tab.innerHTML = `
|
| 98 |
+
<span class="tab-title">${title}</span>
|
| 99 |
+
<span class="tab-status" style="display: none;"><span></span><span></span><span></span></span>
|
| 100 |
+
<span class="tab-close">Γ</span>
|
| 101 |
+
`;
|
| 102 |
+
|
| 103 |
+
// Insert into the dynamic tabs container
|
| 104 |
+
const dynamicTabs = document.getElementById('dynamicTabs');
|
| 105 |
+
dynamicTabs.appendChild(tab);
|
| 106 |
+
|
| 107 |
+
// Create content element - restore saved content if available
|
| 108 |
+
const content = document.createElement('div');
|
| 109 |
+
content.className = 'tab-content';
|
| 110 |
+
content.dataset.contentId = tabId;
|
| 111 |
+
|
| 112 |
+
if (notebook.savedContent) {
|
| 113 |
+
// Restore the saved content (includes all messages)
|
| 114 |
+
content.innerHTML = notebook.savedContent;
|
| 115 |
+
} else {
|
| 116 |
+
// Fallback: create fresh agent content
|
| 117 |
+
content.innerHTML = createAgentContent(type, tabId, title);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
document.querySelector('.main-content').appendChild(content);
|
| 121 |
+
|
| 122 |
+
// Mark as no longer closed and clear saved content
|
| 123 |
+
notebook.isClosed = false;
|
| 124 |
+
delete notebook.savedContent;
|
| 125 |
+
delete notebook.savedTitle;
|
| 126 |
+
|
| 127 |
+
// Switch to the reopened tab
|
| 128 |
+
switchToTab(tabId);
|
| 129 |
+
|
| 130 |
+
// Re-attach event listeners for the restored content
|
| 131 |
+
if (type !== 'command-center') {
|
| 132 |
+
setupInputListeners(content, tabId);
|
| 133 |
+
|
| 134 |
+
// If this is a code agent, start the sandbox proactively
|
| 135 |
+
if (type === 'code') {
|
| 136 |
+
startSandbox(tabId);
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// Update timeline to remove "closed" indicator
|
| 141 |
+
renderTimeline();
|
| 142 |
+
|
| 143 |
+
// Save workspace state
|
| 144 |
+
saveWorkspaceDebounced();
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// Render the full timeline widget
|
| 148 |
+
function renderTimeline() {
|
| 149 |
+
const sidebarContent = document.getElementById('sidebarAgents');
|
| 150 |
+
if (!sidebarContent) return;
|
| 151 |
+
|
| 152 |
+
// Get root agents (those without parents) - always include command center for workspace name
|
| 153 |
+
const rootAgents = Object.entries(timelineData)
|
| 154 |
+
.filter(([id, data]) => data.parentTabId === null);
|
| 155 |
+
|
| 156 |
+
let html = '';
|
| 157 |
+
|
| 158 |
+
for (const [tabId, notebook] of rootAgents) {
|
| 159 |
+
html += renderAgentTimeline(parseInt(tabId), notebook);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
sidebarContent.innerHTML = html;
|
| 163 |
+
|
| 164 |
+
// Update timeline line heights
|
| 165 |
+
updateTimelineLines();
|
| 166 |
+
|
| 167 |
+
// Add click handlers
|
| 168 |
+
sidebarContent.querySelectorAll('.tl-row[data-tab-id]').forEach(row => {
|
| 169 |
+
row.style.cursor = 'pointer';
|
| 170 |
+
row.addEventListener('click', (e) => {
|
| 171 |
+
if (!e.target.closest('.collapse-toggle')) {
|
| 172 |
+
const clickTabId = parseInt(row.dataset.tabId);
|
| 173 |
+
openOrSwitchToTab(clickTabId);
|
| 174 |
+
scrollToTimelineEvent(clickTabId, row.dataset.eventIndex);
|
| 175 |
+
}
|
| 176 |
+
});
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
sidebarContent.querySelectorAll('.agent-box[data-tab-id]').forEach(box => {
|
| 180 |
+
box.style.cursor = 'pointer';
|
| 181 |
+
box.addEventListener('click', (e) => {
|
| 182 |
+
e.stopPropagation(); // Prevent double-firing from parent tl-row
|
| 183 |
+
const clickTabId = parseInt(box.dataset.tabId);
|
| 184 |
+
openOrSwitchToTab(clickTabId);
|
| 185 |
+
});
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
// Add click handler to workspace block header
|
| 189 |
+
sidebarContent.querySelectorAll('.tl-widget[data-tab-id]').forEach(widget => {
|
| 190 |
+
const workspaceBlock = widget.querySelector('.workspace-block');
|
| 191 |
+
if (workspaceBlock) {
|
| 192 |
+
workspaceBlock.style.cursor = 'pointer';
|
| 193 |
+
workspaceBlock.addEventListener('click', (e) => {
|
| 194 |
+
e.stopPropagation();
|
| 195 |
+
const clickTabId = parseInt(widget.dataset.tabId);
|
| 196 |
+
openOrSwitchToTab(clickTabId);
|
| 197 |
+
});
|
| 198 |
+
}
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
sidebarContent.querySelectorAll('.collapse-toggle').forEach(toggle => {
|
| 202 |
+
toggle.addEventListener('click', (e) => {
|
| 203 |
+
e.stopPropagation();
|
| 204 |
+
// Toggle is now in .tl-row.has-agent, find the sibling .tl-nested
|
| 205 |
+
const row = toggle.closest('.tl-row.has-agent');
|
| 206 |
+
if (row) {
|
| 207 |
+
const nested = row.nextElementSibling;
|
| 208 |
+
if (nested && nested.classList.contains('tl-nested')) {
|
| 209 |
+
const childTabId = nested.dataset.childTabId;
|
| 210 |
+
nested.classList.toggle('collapsed');
|
| 211 |
+
toggle.classList.toggle('collapsed');
|
| 212 |
+
// Track collapsed state
|
| 213 |
+
if (childTabId) {
|
| 214 |
+
if (nested.classList.contains('collapsed')) {
|
| 215 |
+
collapsedAgents.add(childTabId);
|
| 216 |
+
} else {
|
| 217 |
+
collapsedAgents.delete(childTabId);
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
updateTimelineLines();
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
});
|
| 224 |
+
});
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// Render a single agent's timeline (recursive for nested)
|
| 228 |
+
function renderAgentTimeline(tabId, notebook, isNested = false) {
|
| 229 |
+
const isActive = activeTabId === tabId;
|
| 230 |
+
const isClosed = notebook.isClosed || false;
|
| 231 |
+
const typeLabel = getTypeLabel(notebook.type);
|
| 232 |
+
|
| 233 |
+
let html = `<div class="tl-widget${isNested ? '' : ' compact'}${isActive ? ' active' : ''}${isClosed ? ' closed' : ''}" data-tab-id="${tabId}">`;
|
| 234 |
+
|
| 235 |
+
if (!isNested) {
|
| 236 |
+
// Workspace header - left edge aligned with vertical line
|
| 237 |
+
html += `<div class="workspace-block">
|
| 238 |
+
<div class="workspace-label">PROJECT</div>
|
| 239 |
+
<div class="workspace-name">${escapeHtml(notebook.title)}</div>
|
| 240 |
+
</div>`;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
html += `<div class="tl">`;
|
| 244 |
+
|
| 245 |
+
// Group events: consecutive assistant events form a group
|
| 246 |
+
const groups = [];
|
| 247 |
+
for (const event of notebook.events) {
|
| 248 |
+
if (event.type === 'assistant') {
|
| 249 |
+
const lastGroup = groups[groups.length - 1];
|
| 250 |
+
if (lastGroup && lastGroup.type === 'assistant') {
|
| 251 |
+
lastGroup.events.push(event);
|
| 252 |
+
} else {
|
| 253 |
+
groups.push({ type: 'assistant', events: [event] });
|
| 254 |
+
}
|
| 255 |
+
} else {
|
| 256 |
+
groups.push({ type: event.type, events: [event] });
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// Render groups
|
| 261 |
+
for (const group of groups) {
|
| 262 |
+
if (group.type === 'assistant') {
|
| 263 |
+
if (!showAllTurns && group.events.length > 1) {
|
| 264 |
+
// Collapsed: single summary dot with turn count label β use first event's index
|
| 265 |
+
const firstEvent = group.events[0];
|
| 266 |
+
html += `
|
| 267 |
+
<div class="tl-row turn" data-tab-id="${tabId}" data-event-index="${firstEvent.index}">
|
| 268 |
+
<div class="tl-dot"></div>
|
| 269 |
+
<span class="tl-turn-count">${group.events.length} turns</span>
|
| 270 |
+
</div>`;
|
| 271 |
+
} else {
|
| 272 |
+
// Expanded or single: render each dot individually
|
| 273 |
+
for (const event of group.events) {
|
| 274 |
+
if (event.meta?.tag) {
|
| 275 |
+
html += `
|
| 276 |
+
<div class="tl-row turn" data-tab-id="${tabId}" data-event-index="${event.index}">
|
| 277 |
+
<div class="tl-dot"></div>
|
| 278 |
+
<div class="tl-tool"><span class="tl-tool-tag">${event.meta.tag}</span>${event.content ? `<span class="tl-tool-text">${escapeHtml(event.content)}</span>` : ''}</div>
|
| 279 |
+
</div>`;
|
| 280 |
+
} else {
|
| 281 |
+
html += `
|
| 282 |
+
<div class="tl-row turn" data-tab-id="${tabId}" data-event-index="${event.index}">
|
| 283 |
+
<div class="tl-dot"></div>
|
| 284 |
+
<span class="tl-label">${escapeHtml(event.content)}</span>
|
| 285 |
+
</div>`;
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
} else if (group.type === 'user') {
|
| 290 |
+
const event = group.events[0];
|
| 291 |
+
html += `
|
| 292 |
+
<div class="tl-row turn user" data-tab-id="${tabId}" data-event-index="${event.index}">
|
| 293 |
+
<div class="tl-dot"></div>
|
| 294 |
+
<span class="tl-label">${escapeHtml(event.content)}</span>
|
| 295 |
+
</div>`;
|
| 296 |
+
} else if (group.type === 'agent') {
|
| 297 |
+
const event = group.events[0];
|
| 298 |
+
if (event.childTabId !== null) {
|
| 299 |
+
const childNotebook = timelineData[event.childTabId];
|
| 300 |
+
if (childNotebook) {
|
| 301 |
+
const childTypeLabel = getTypeLabel(childNotebook.type);
|
| 302 |
+
const childIsGenerating = childNotebook.isGenerating;
|
| 303 |
+
const turnCount = childNotebook.events.length;
|
| 304 |
+
|
| 305 |
+
const hasEvents = childNotebook.events.length > 0;
|
| 306 |
+
const isCollapsed = collapsedAgents.has(String(event.childTabId));
|
| 307 |
+
const isChildActive = activeTabId === event.childTabId;
|
| 308 |
+
html += `
|
| 309 |
+
<div class="tl-row has-agent${hasEvents ? ' has-nested' : ''}" data-tab-id="${event.childTabId}" data-event-index="${event.index}">
|
| 310 |
+
<div class="tl-dot"></div>
|
| 311 |
+
<div class="agent-box${isChildActive ? ' active' : ''}" data-tab-id="${event.childTabId}">
|
| 312 |
+
<div class="agent-header">
|
| 313 |
+
${hasEvents ? `<div class="collapse-toggle${isCollapsed ? ' collapsed' : ''}"></div>` : ''}
|
| 314 |
+
<span>${childTypeLabel}</span>
|
| 315 |
+
</div>
|
| 316 |
+
<div class="agent-body">
|
| 317 |
+
<span class="agent-body-text">${escapeHtml(childNotebook.title)}</span>
|
| 318 |
+
<div class="agent-status">
|
| 319 |
+
<span>${turnCount} turns</span>
|
| 320 |
+
${childIsGenerating ? `
|
| 321 |
+
<div class="agent-progress"><span></span><span></span><span></span></div>
|
| 322 |
+
` : `
|
| 323 |
+
<div class="agent-done${childNotebook.aborted ? ' aborted' : ''}"></div>
|
| 324 |
+
`}
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
</div>`;
|
| 329 |
+
|
| 330 |
+
// Render nested timeline if child has events
|
| 331 |
+
if (hasEvents) {
|
| 332 |
+
const isComplete = !childIsGenerating;
|
| 333 |
+
html += `
|
| 334 |
+
<div class="tl-nested${isComplete ? ' complete' : ''}${isCollapsed ? ' collapsed' : ''}" data-child-tab-id="${event.childTabId}">
|
| 335 |
+
${renderAgentTimeline(event.childTabId, childNotebook, true)}
|
| 336 |
+
</div>`;
|
| 337 |
+
// Return row with dot on parent line - only when subagent is complete
|
| 338 |
+
if (isComplete) {
|
| 339 |
+
html += `
|
| 340 |
+
<div class="tl-row tl-return">
|
| 341 |
+
<div class="tl-dot"></div>
|
| 342 |
+
<div class="tl-return-connector"></div>
|
| 343 |
+
</div>`;
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
// Show generating indicator if currently generating and no events yet
|
| 352 |
+
if (notebook.isGenerating && notebook.events.length === 0) {
|
| 353 |
+
html += `
|
| 354 |
+
<div class="tl-row turn">
|
| 355 |
+
<div class="tl-dot generating"></div>
|
| 356 |
+
</div>`;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
html += `</div></div>`;
|
| 360 |
+
|
| 361 |
+
return html;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
// Update timeline line heights and return line positions
|
| 365 |
+
function updateTimelineLines() {
|
| 366 |
+
document.querySelectorAll('.tl').forEach(tl => {
|
| 367 |
+
const rows = Array.from(tl.children).filter(el => el.classList.contains('tl-row'));
|
| 368 |
+
if (rows.length < 1) return;
|
| 369 |
+
|
| 370 |
+
const firstRow = rows[0];
|
| 371 |
+
const firstDot = firstRow.querySelector('.tl-dot');
|
| 372 |
+
if (!firstDot) return;
|
| 373 |
+
|
| 374 |
+
const lastRow = rows[rows.length - 1];
|
| 375 |
+
const lastDot = lastRow.querySelector('.tl-dot');
|
| 376 |
+
if (!lastDot) return;
|
| 377 |
+
|
| 378 |
+
const tlRect = tl.getBoundingClientRect();
|
| 379 |
+
const firstDotRect = firstDot.getBoundingClientRect();
|
| 380 |
+
const lastDotRect = lastDot.getBoundingClientRect();
|
| 381 |
+
|
| 382 |
+
const dotOffset = 2;
|
| 383 |
+
const isNested = tl.closest('.tl-nested') !== null;
|
| 384 |
+
const lineTop = isNested ? -6 : (firstDotRect.top - tlRect.top + dotOffset);
|
| 385 |
+
const lineBottom = lastDotRect.top - tlRect.top + dotOffset;
|
| 386 |
+
|
| 387 |
+
tl.style.setProperty('--line-top', lineTop + 'px');
|
| 388 |
+
tl.style.setProperty('--line-height', (lineBottom - lineTop) + 'px');
|
| 389 |
+
});
|
| 390 |
+
|
| 391 |
+
// Position return rows to align with last nested dot or agent-box
|
| 392 |
+
document.querySelectorAll('.tl-nested').forEach(nested => {
|
| 393 |
+
const returnRow = nested.nextElementSibling;
|
| 394 |
+
if (!returnRow || !returnRow.classList.contains('tl-return')) return;
|
| 395 |
+
|
| 396 |
+
const isCollapsed = nested.classList.contains('collapsed');
|
| 397 |
+
const connector = returnRow.querySelector('.tl-return-connector');
|
| 398 |
+
const returnDot = returnRow.querySelector('.tl-dot');
|
| 399 |
+
if (!returnDot) return;
|
| 400 |
+
|
| 401 |
+
// Reset position first to get accurate baseline measurement
|
| 402 |
+
returnRow.style.top = '0';
|
| 403 |
+
|
| 404 |
+
// Find the agent-box in the previous sibling row
|
| 405 |
+
const agentRow = nested.previousElementSibling;
|
| 406 |
+
const agentBox = agentRow?.querySelector('.agent-box');
|
| 407 |
+
|
| 408 |
+
if (isCollapsed && agentBox) {
|
| 409 |
+
// When collapsed: align return dot with bottom of agent-box
|
| 410 |
+
const agentBoxRect = agentBox.getBoundingClientRect();
|
| 411 |
+
const returnDotRect = returnDot.getBoundingClientRect();
|
| 412 |
+
|
| 413 |
+
// Align return dot's top with agent-box bottom
|
| 414 |
+
const yOffset = agentBoxRect.bottom - returnDotRect.top - 3;
|
| 415 |
+
returnRow.style.top = yOffset + 'px';
|
| 416 |
+
|
| 417 |
+
if (connector) {
|
| 418 |
+
// Connector goes from return dot to agent-box
|
| 419 |
+
const connectorWidth = agentBoxRect.left - returnDotRect.right;
|
| 420 |
+
connector.style.width = Math.max(0, connectorWidth) + 'px';
|
| 421 |
+
connector.style.background = 'var(--theme-accent)';
|
| 422 |
+
}
|
| 423 |
+
} else {
|
| 424 |
+
// When expanded: align return dot with last nested dot
|
| 425 |
+
const nestedTl = nested.querySelector('.tl');
|
| 426 |
+
if (!nestedTl) return;
|
| 427 |
+
|
| 428 |
+
const nestedRows = Array.from(nestedTl.children).filter(el => el.classList.contains('tl-row'));
|
| 429 |
+
if (nestedRows.length === 0) return;
|
| 430 |
+
|
| 431 |
+
const lastNestedRow = nestedRows[nestedRows.length - 1];
|
| 432 |
+
const lastNestedDot = lastNestedRow.querySelector('.tl-dot');
|
| 433 |
+
|
| 434 |
+
if (lastNestedDot && returnDot) {
|
| 435 |
+
const lastNestedRect = lastNestedDot.getBoundingClientRect();
|
| 436 |
+
const returnDotRect = returnDot.getBoundingClientRect();
|
| 437 |
+
|
| 438 |
+
// Calculate offset to align Y positions
|
| 439 |
+
const yOffset = lastNestedRect.top - returnDotRect.top;
|
| 440 |
+
returnRow.style.top = yOffset + 'px';
|
| 441 |
+
|
| 442 |
+
// Connector width: from return dot to nested timeline's vertical line
|
| 443 |
+
if (connector) {
|
| 444 |
+
const nestedTlRect = nestedTl.getBoundingClientRect();
|
| 445 |
+
// Nested vertical line is at left: 2px from nested .tl
|
| 446 |
+
const nestedLineX = nestedTlRect.left + 2;
|
| 447 |
+
const connectorWidth = nestedLineX - returnDotRect.right;
|
| 448 |
+
|
| 449 |
+
connector.style.width = Math.max(0, connectorWidth) + 'px';
|
| 450 |
+
connector.style.background = 'var(--border-primary)';
|
| 451 |
+
}
|
| 452 |
+
}
|
| 453 |
+
}
|
| 454 |
+
});
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
let IS_MULTI_USER = false;
|
| 458 |
+
|
| 459 |
+
function showUsernameOverlay() {
|
| 460 |
+
return new Promise(resolve => {
|
| 461 |
+
const overlay = document.getElementById('usernameOverlay');
|
| 462 |
+
const input = document.getElementById('usernameInput');
|
| 463 |
+
const submit = document.getElementById('usernameSubmit');
|
| 464 |
+
const warning = document.getElementById('usernameWarning');
|
| 465 |
+
if (!overlay) { resolve(); return; }
|
| 466 |
+
overlay.style.display = 'flex';
|
| 467 |
+
input.value = '';
|
| 468 |
+
warning.style.display = 'none';
|
| 469 |
+
input.focus();
|
| 470 |
+
|
| 471 |
+
// Check if username exists on input change (debounced)
|
| 472 |
+
let checkTimeout;
|
| 473 |
+
const checkExists = async () => {
|
| 474 |
+
const name = sanitizeUsername(input.value);
|
| 475 |
+
if (!name) { warning.style.display = 'none'; return; }
|
| 476 |
+
try {
|
| 477 |
+
const resp = await fetch(`/api/user/exists/${encodeURIComponent(name)}`);
|
| 478 |
+
const data = await resp.json();
|
| 479 |
+
if (data.exists) {
|
| 480 |
+
warning.textContent = `"${name}" already has a workspace β you'll share it`;
|
| 481 |
+
warning.style.display = 'block';
|
| 482 |
+
} else {
|
| 483 |
+
warning.style.display = 'none';
|
| 484 |
+
}
|
| 485 |
+
} catch { warning.style.display = 'none'; }
|
| 486 |
+
};
|
| 487 |
+
input.oninput = () => { clearTimeout(checkTimeout); checkTimeout = setTimeout(checkExists, 300); };
|
| 488 |
+
|
| 489 |
+
const doSubmit = () => {
|
| 490 |
+
const name = sanitizeUsername(input.value);
|
| 491 |
+
if (!name) return;
|
| 492 |
+
SESSION_ID = name;
|
| 493 |
+
localStorage.setItem('agentui_username', name);
|
| 494 |
+
overlay.style.display = 'none';
|
| 495 |
+
updateUserIndicator();
|
| 496 |
+
input.oninput = null;
|
| 497 |
+
resolve();
|
| 498 |
+
};
|
| 499 |
+
submit.onclick = doSubmit;
|
| 500 |
+
input.onkeydown = (e) => { if (e.key === 'Enter') doSubmit(); };
|
| 501 |
+
});
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
function updateUserIndicator() {
|
| 505 |
+
const indicator = document.getElementById('userIndicator');
|
| 506 |
+
const nameEl = document.getElementById('userIndicatorName');
|
| 507 |
+
if (!indicator || !nameEl) return;
|
| 508 |
+
if (IS_MULTI_USER && SESSION_ID) {
|
| 509 |
+
nameEl.textContent = SESSION_ID;
|
| 510 |
+
indicator.title = 'Click to switch user';
|
| 511 |
+
indicator.style.display = 'flex';
|
| 512 |
+
indicator.onclick = async () => {
|
| 513 |
+
await showUsernameOverlay();
|
| 514 |
+
window.location.reload();
|
| 515 |
+
};
|
| 516 |
+
} else {
|
| 517 |
+
indicator.style.display = 'none';
|
| 518 |
+
}
|
| 519 |
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================================
|
| 2 |
+
// Multi-user session management
|
| 3 |
+
// ============================================================
|
| 4 |
+
let SESSION_ID = localStorage.getItem('agentui_username') || '';
|
| 5 |
+
|
| 6 |
+
function apiFetch(url, options = {}) {
|
| 7 |
+
if (SESSION_ID) {
|
| 8 |
+
options.headers = { ...options.headers, 'X-Session-ID': SESSION_ID };
|
| 9 |
+
}
|
| 10 |
+
return fetch(url, options);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function sanitizeUsername(name) {
|
| 14 |
+
return name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '').substring(0, 30);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// ============================================================
|
| 18 |
+
// Agent Type Registry β populated from backend /api/agents at startup
|
| 19 |
+
// To add a new agent type, add an entry in backend/agents.py (single source of truth)
|
| 20 |
+
// ============================================================
|
| 21 |
+
let AGENT_REGISTRY = {};
|
| 22 |
+
// Virtual types used only in timeline rendering (not real agents)
|
| 23 |
+
const VIRTUAL_TYPE_LABELS = { search: 'SEARCH', browse: 'BROWSE' };
|
| 24 |
+
|
| 25 |
+
// Derived helpers from registry
|
| 26 |
+
function getTypeLabel(type) {
|
| 27 |
+
if (AGENT_REGISTRY[type]) return AGENT_REGISTRY[type].label;
|
| 28 |
+
if (VIRTUAL_TYPE_LABELS[type]) return VIRTUAL_TYPE_LABELS[type];
|
| 29 |
+
return type.toUpperCase();
|
| 30 |
+
}
|
| 31 |
+
function getPlaceholder(type) {
|
| 32 |
+
return AGENT_REGISTRY[type]?.placeholder || 'Enter message...';
|
| 33 |
+
}
|
| 34 |
+
function getDefaultCounters() {
|
| 35 |
+
const counters = {};
|
| 36 |
+
for (const [key, agent] of Object.entries(AGENT_REGISTRY)) {
|
| 37 |
+
if (agent.hasCounter) counters[key] = 0;
|
| 38 |
+
}
|
| 39 |
+
return counters;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// State management
|
| 43 |
+
let tabCounter = 1;
|
| 44 |
+
let activeTabId = 0;
|
| 45 |
+
let currentSession = null; // Name of the current session
|
| 46 |
+
const collapsedAgents = new Set(); // Track collapsed agent tab IDs
|
| 47 |
+
let researchQueryTabIds = {}; // queryIndex -> virtual tabId for research timeline
|
| 48 |
+
let showAllTurns = true; // Toggle to show/hide individual assistant dots
|
| 49 |
+
|
| 50 |
+
// Fetch random isotope name from backend
|
| 51 |
+
async function generateSessionName() {
|
| 52 |
+
try {
|
| 53 |
+
const response = await apiFetch('/api/sessions/random-name');
|
| 54 |
+
const data = await response.json();
|
| 55 |
+
return data.name;
|
| 56 |
+
} catch (error) {
|
| 57 |
+
// Fallback to timestamp if API fails
|
| 58 |
+
return `session-${Date.now()}`;
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
let settings = {
|
| 63 |
+
// New provider/model structure
|
| 64 |
+
providers: {}, // providerId -> {name, endpoint, token}
|
| 65 |
+
models: {}, // modelId -> {name, providerId, modelId (API model string)}
|
| 66 |
+
agents: {}, // Populated after AGENT_REGISTRY is fetched
|
| 67 |
+
// Service API keys
|
| 68 |
+
e2bKey: '',
|
| 69 |
+
serperKey: '',
|
| 70 |
+
hfToken: '',
|
| 71 |
+
// Image model selections (model IDs from the models list)
|
| 72 |
+
imageGenModel: '',
|
| 73 |
+
imageEditModel: '',
|
| 74 |
+
// Research settings
|
| 75 |
+
researchSubAgentModel: '',
|
| 76 |
+
researchParallelWorkers: null,
|
| 77 |
+
researchMaxWebsites: null,
|
| 78 |
+
// UI settings
|
| 79 |
+
themeColor: 'forest',
|
| 80 |
+
// Schema version for migrations
|
| 81 |
+
settingsVersion: 2
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
// Track action widgets for result updates (maps tabId -> widget element)
|
| 85 |
+
const actionWidgets = {};
|
| 86 |
+
|
| 87 |
+
// Track tool call IDs for result updates (maps tabId -> tool_call_id)
|
| 88 |
+
const toolCallIds = {};
|
| 89 |
+
|
| 90 |
+
// Global figure/image registry populated by sub-agents for cross-agent reference resolution
|
| 91 |
+
// Maps "figure_1" -> {type, data} and "image_1" -> {type: "png", data: base64}
|
| 92 |
+
const globalFigureRegistry = {};
|
| 93 |
+
|
| 94 |
+
// Debug: per-tab LLM call history (populated by SSE debug_call_input/output events)
|
| 95 |
+
// Maps tabId -> [{call_number, timestamp, input, output, error}]
|
| 96 |
+
const debugHistory = {};
|
| 97 |
+
|
| 98 |
+
// Track agents by task_id for reuse (maps task_id -> tabId)
|
| 99 |
+
const taskIdToTabId = {};
|
| 100 |
+
|
| 101 |
+
// Whether command center input is blocked waiting for agents to finish
|
| 102 |
+
let commandInputBlocked = false;
|
| 103 |
+
|
| 104 |
+
// Count of agent launches that haven't started generating yet (handles race condition)
|
| 105 |
+
let pendingAgentLaunches = 0;
|
| 106 |
+
|
| 107 |
+
// Track agent counters for each type (derived from registry)
|
| 108 |
+
let agentCounters = getDefaultCounters();
|
| 109 |
+
|
| 110 |
+
// Debounce timer for workspace saving
|
| 111 |
+
let saveWorkspaceTimer = null;
|
| 112 |
+
|
| 113 |
+
// Abort controllers for in-flight fetch requests (tabId -> AbortController)
|
| 114 |
+
const activeAbortControllers = {};
|
| 115 |
+
|
| 116 |
+
// Timeline data structure for sidebar
|
| 117 |
+
// Maps tabId -> { type, title, events: [{type: 'user'|'assistant'|'agent', content, childTabId?}], parentTabId?, isGenerating }
|
| 118 |
+
const timelineData = {
|
| 119 |
+
0: { type: 'command', title: 'Task Center', events: [], parentTabId: null, isGenerating: false }
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
// Reset all local state for session switching (without page reload)
|
| 123 |
+
function resetLocalState() {
|
| 124 |
+
// Reset counters
|
| 125 |
+
tabCounter = 1;
|
| 126 |
+
activeTabId = 0;
|
| 127 |
+
currentSession = null;
|
| 128 |
+
collapsedAgents.clear();
|
| 129 |
+
|
| 130 |
+
// Clear object maps
|
| 131 |
+
Object.keys(actionWidgets).forEach(k => delete actionWidgets[k]);
|
| 132 |
+
Object.keys(toolCallIds).forEach(k => delete toolCallIds[k]);
|
| 133 |
+
Object.keys(globalFigureRegistry).forEach(k => delete globalFigureRegistry[k]);
|
| 134 |
+
Object.keys(debugHistory).forEach(k => delete debugHistory[k]);
|
| 135 |
+
Object.keys(taskIdToTabId).forEach(k => delete taskIdToTabId[k]);
|
| 136 |
+
researchQueryTabIds = {};
|
| 137 |
+
showAllTurns = true;
|
| 138 |
+
agentCounters = getDefaultCounters();
|
| 139 |
+
|
| 140 |
+
// Reset sidebar checkboxes
|
| 141 |
+
const compactCb = document.getElementById('compactViewCheckbox');
|
| 142 |
+
if (compactCb) compactCb.checked = false;
|
| 143 |
+
const collapseAgentsCb = document.getElementById('collapseAgentsCheckbox');
|
| 144 |
+
if (collapseAgentsCb) collapseAgentsCb.checked = false;
|
| 145 |
+
const collapseToolsCb = document.getElementById('collapseToolsCheckbox');
|
| 146 |
+
if (collapseToolsCb) collapseToolsCb.checked = false;
|
| 147 |
+
|
| 148 |
+
// Reset timeline data
|
| 149 |
+
Object.keys(timelineData).forEach(k => delete timelineData[k]);
|
| 150 |
+
timelineData[0] = { type: 'command', title: 'Task Center', events: [], parentTabId: null, isGenerating: false };
|
| 151 |
+
|
| 152 |
+
// Clear dynamic tabs from DOM
|
| 153 |
+
const dynamicTabs = document.getElementById('dynamicTabs');
|
| 154 |
+
if (dynamicTabs) dynamicTabs.innerHTML = '';
|
| 155 |
+
|
| 156 |
+
// Remove all dynamic tab content elements (keep tab-content[data-content-id="0"])
|
| 157 |
+
document.querySelectorAll('.tab-content').forEach(el => {
|
| 158 |
+
if (el.dataset.contentId !== '0') el.remove();
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
// Clear command center messages
|
| 162 |
+
const commandMessages = document.getElementById('messages-command');
|
| 163 |
+
if (commandMessages) commandMessages.innerHTML = '';
|
| 164 |
+
|
| 165 |
+
// Close any open panels
|
| 166 |
+
closeAllPanels();
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function escapeHtml(text) {
|
| 170 |
+
const div = document.createElement('div');
|
| 171 |
+
div.textContent = text;
|
| 172 |
+
return div.innerHTML;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function formatDate(timestamp) {
|
| 176 |
+
const date = new Date(timestamp * 1000);
|
| 177 |
+
const now = new Date();
|
| 178 |
+
const diff = now - date;
|
| 179 |
+
|
| 180 |
+
if (diff < 60000) return 'just now';
|
| 181 |
+
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
| 182 |
+
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
| 183 |
+
if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago';
|
| 184 |
+
|
| 185 |
+
return date.toLocaleDateString();
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
// ============================================================
|
| 189 |
+
// Shared UI helpers (deduplication)
|
| 190 |
+
// ============================================================
|
| 191 |
+
|
| 192 |
+
// Wire send-button, textarea auto-resize, and Enter-to-send for any agent tab
|
| 193 |
+
function setupInputListeners(container, tabId) {
|
| 194 |
+
const input = container.querySelector('textarea');
|
| 195 |
+
const sendBtn = container.querySelector('.input-container button');
|
| 196 |
+
if (!input || !sendBtn) return;
|
| 197 |
+
|
| 198 |
+
sendBtn.addEventListener('click', () => sendMessage(tabId));
|
| 199 |
+
|
| 200 |
+
input.addEventListener('input', () => {
|
| 201 |
+
input.style.height = 'auto';
|
| 202 |
+
input.style.height = Math.min(input.scrollHeight, 200) + 'px';
|
| 203 |
+
input.style.overflowY = input.scrollHeight > 200 ? 'auto' : 'hidden';
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
input.addEventListener('keydown', (e) => {
|
| 207 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 208 |
+
e.preventDefault();
|
| 209 |
+
sendMessage(tabId);
|
| 210 |
+
}
|
| 211 |
+
});
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// Wire click-to-collapse on tool cells, code cells, and action widgets
|
| 215 |
+
function setupCollapseToggle(cell, labelSelector) {
|
| 216 |
+
const label = cell.querySelector(labelSelector || '.tool-cell-label, .code-cell-label');
|
| 217 |
+
if (!label) return;
|
| 218 |
+
label.addEventListener('click', () => {
|
| 219 |
+
cell.classList.toggle('collapsed');
|
| 220 |
+
const toggle = cell.querySelector('.widget-collapse-toggle');
|
| 221 |
+
if (toggle) toggle.classList.toggle('collapsed');
|
| 222 |
+
});
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Close all right-side panels (settings, debug, files, sessions)
|
| 226 |
+
function closeAllPanels() {
|
| 227 |
+
const app = document.querySelector('.app-container');
|
| 228 |
+
for (const [panelId, btnId, cls] of [
|
| 229 |
+
['settingsPanel', 'settingsBtn', 'panel-open'],
|
| 230 |
+
['debugPanel', 'debugBtn', 'panel-open'],
|
| 231 |
+
['filesPanel', 'filesBtn', 'files-panel-open'],
|
| 232 |
+
['sessionsPanel', 'sessionsBtn', 'sessions-panel-open'],
|
| 233 |
+
]) {
|
| 234 |
+
document.getElementById(panelId)?.classList.remove('active');
|
| 235 |
+
document.getElementById(btnId)?.classList.remove('active');
|
| 236 |
+
if (cls && app) app.classList.remove(cls);
|
| 237 |
+
}
|
| 238 |
+
}
|
|
@@ -0,0 +1,641 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ============================================
|
| 2 |
+
// Workspace State Persistence
|
| 3 |
+
// ============================================
|
| 4 |
+
|
| 5 |
+
async function loadWorkspace() {
|
| 6 |
+
try {
|
| 7 |
+
const response = await apiFetch('/api/workspace');
|
| 8 |
+
if (response.ok) {
|
| 9 |
+
const workspace = await response.json();
|
| 10 |
+
console.log('Workspace loaded:', workspace);
|
| 11 |
+
restoreWorkspace(workspace);
|
| 12 |
+
}
|
| 13 |
+
} catch (e) {
|
| 14 |
+
console.log('Could not load workspace from backend:', e);
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function restoreWorkspace(workspace) {
|
| 19 |
+
// Restore counters
|
| 20 |
+
tabCounter = workspace.tabCounter || 1;
|
| 21 |
+
agentCounters = workspace.agentCounters || workspace.notebookCounters || getDefaultCounters();
|
| 22 |
+
|
| 23 |
+
// Restore timeline data before tabs so renderTimeline works
|
| 24 |
+
if (workspace.timelineData) {
|
| 25 |
+
Object.keys(timelineData).forEach(k => delete timelineData[k]);
|
| 26 |
+
for (const [tabId, data] of Object.entries(workspace.timelineData)) {
|
| 27 |
+
timelineData[tabId] = data;
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// Restore debug history
|
| 32 |
+
if (workspace.debugHistory) {
|
| 33 |
+
Object.keys(debugHistory).forEach(k => delete debugHistory[k]);
|
| 34 |
+
for (const [tabId, calls] of Object.entries(workspace.debugHistory)) {
|
| 35 |
+
debugHistory[tabId] = calls;
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Restore tabs (skip command center as it already exists in HTML)
|
| 40 |
+
const tabs = workspace.tabs || [];
|
| 41 |
+
for (const tabData of tabs) {
|
| 42 |
+
if (tabData.id === 0) {
|
| 43 |
+
// Restore command center messages
|
| 44 |
+
restoreTabMessages(tabData);
|
| 45 |
+
} else {
|
| 46 |
+
// Create and restore other tabs
|
| 47 |
+
restoreTab(tabData);
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// Switch to the active tab
|
| 52 |
+
if (workspace.activeTabId !== undefined) {
|
| 53 |
+
switchToTab(workspace.activeTabId);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Render timeline after everything is restored
|
| 57 |
+
renderTimeline();
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function restoreTab(tabData) {
|
| 61 |
+
// Create tab element
|
| 62 |
+
const tab = document.createElement('div');
|
| 63 |
+
tab.className = 'tab';
|
| 64 |
+
tab.dataset.tabId = tabData.id;
|
| 65 |
+
tab.innerHTML = `
|
| 66 |
+
<span class="tab-title">${tabData.title || getTypeLabel(tabData.type) || 'TAB'}</span>
|
| 67 |
+
<span class="tab-status" style="display: none;"><span></span><span></span><span></span></span>
|
| 68 |
+
<span class="tab-close">Γ</span>
|
| 69 |
+
`;
|
| 70 |
+
|
| 71 |
+
// Insert into dynamic tabs container
|
| 72 |
+
const dynamicTabs = document.getElementById('dynamicTabs');
|
| 73 |
+
dynamicTabs.appendChild(tab);
|
| 74 |
+
|
| 75 |
+
// Create content element
|
| 76 |
+
const content = document.createElement('div');
|
| 77 |
+
content.className = 'tab-content';
|
| 78 |
+
content.dataset.contentId = tabData.id;
|
| 79 |
+
content.innerHTML = createAgentContent(tabData.type, tabData.id);
|
| 80 |
+
document.querySelector('.main-content').appendChild(content);
|
| 81 |
+
|
| 82 |
+
// Add event listeners for the new content
|
| 83 |
+
setupInputListeners(content, tabData.id);
|
| 84 |
+
|
| 85 |
+
// Restore messages
|
| 86 |
+
restoreTabMessages(tabData);
|
| 87 |
+
|
| 88 |
+
// If this is a code agent, start the sandbox proactively
|
| 89 |
+
if (tabData.type === 'code') {
|
| 90 |
+
startSandbox(tabData.id);
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function restoreTabMessages(tabData) {
|
| 95 |
+
const content = document.querySelector(`[data-content-id="${tabData.id}"]`);
|
| 96 |
+
if (!content) return;
|
| 97 |
+
|
| 98 |
+
const chatContainer = content.querySelector('.chat-container');
|
| 99 |
+
if (!chatContainer) return;
|
| 100 |
+
|
| 101 |
+
// Remove welcome message if restoring messages
|
| 102 |
+
if (tabData.messages && tabData.messages.length > 0) {
|
| 103 |
+
const welcomeMsg = chatContainer.querySelector('.welcome-message');
|
| 104 |
+
if (welcomeMsg) {
|
| 105 |
+
welcomeMsg.remove();
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Restore each message - messages are now flat, each with its own type
|
| 110 |
+
for (const msg of (tabData.messages || [])) {
|
| 111 |
+
if (msg.role === 'user') {
|
| 112 |
+
const userMsg = document.createElement('div');
|
| 113 |
+
userMsg.className = 'message user';
|
| 114 |
+
userMsg.innerHTML = `<div class="message-content">${parseMarkdown(msg.content || '')}</div>`;
|
| 115 |
+
linkifyFilePaths(userMsg);
|
| 116 |
+
chatContainer.appendChild(userMsg);
|
| 117 |
+
} else if (msg.role === 'assistant') {
|
| 118 |
+
// New flat structure: each message has a type field
|
| 119 |
+
switch (msg.type) {
|
| 120 |
+
case 'text':
|
| 121 |
+
const assistantMsg = document.createElement('div');
|
| 122 |
+
assistantMsg.className = 'message assistant';
|
| 123 |
+
const textDiv = document.createElement('div');
|
| 124 |
+
textDiv.className = 'message-content';
|
| 125 |
+
textDiv.innerHTML = msg.html || parseMarkdown(msg.content || '');
|
| 126 |
+
assistantMsg.appendChild(textDiv);
|
| 127 |
+
chatContainer.appendChild(assistantMsg);
|
| 128 |
+
break;
|
| 129 |
+
|
| 130 |
+
case 'action-widget':
|
| 131 |
+
const widget = renderActionWidget(msg);
|
| 132 |
+
chatContainer.appendChild(widget);
|
| 133 |
+
break;
|
| 134 |
+
|
| 135 |
+
case 'code-cell':
|
| 136 |
+
const codeCell = renderCodeCell(msg);
|
| 137 |
+
chatContainer.appendChild(codeCell);
|
| 138 |
+
break;
|
| 139 |
+
|
| 140 |
+
case 'tool-call':
|
| 141 |
+
// Restore hidden tool-call element for getConversationHistory()
|
| 142 |
+
const tcMsg = document.createElement('div');
|
| 143 |
+
tcMsg.className = 'message assistant';
|
| 144 |
+
tcMsg.style.display = 'none';
|
| 145 |
+
tcMsg.setAttribute('data-tool-call', JSON.stringify(msg.data));
|
| 146 |
+
chatContainer.appendChild(tcMsg);
|
| 147 |
+
break;
|
| 148 |
+
|
| 149 |
+
case 'tool-cell':
|
| 150 |
+
const toolCell = document.createElement('div');
|
| 151 |
+
toolCell.className = 'tool-cell';
|
| 152 |
+
toolCell.setAttribute('data-tool-name', msg.toolName || '');
|
| 153 |
+
const toolDesc = msg.input ? `<span class="tool-cell-desc">${escapeHtml(msg.input)}</span>` : '';
|
| 154 |
+
let toolCellHtml = `<div class="tool-cell-label"><div class="widget-collapse-toggle"></div><span>${escapeHtml(msg.label || '')}</span>${toolDesc}</div>`;
|
| 155 |
+
toolCellHtml += `<div class="tool-cell-input">${escapeHtml(msg.input || '')}</div>`;
|
| 156 |
+
if (msg.outputHtml) {
|
| 157 |
+
toolCellHtml += `<div class="tool-cell-output">${msg.outputHtml}</div>`;
|
| 158 |
+
}
|
| 159 |
+
toolCell.innerHTML = toolCellHtml;
|
| 160 |
+
setupCollapseToggle(toolCell, '.tool-cell-label');
|
| 161 |
+
chatContainer.appendChild(toolCell);
|
| 162 |
+
break;
|
| 163 |
+
|
| 164 |
+
case 'result-preview':
|
| 165 |
+
const preview = renderResultPreview(msg);
|
| 166 |
+
chatContainer.appendChild(preview);
|
| 167 |
+
break;
|
| 168 |
+
|
| 169 |
+
case 'agent-result':
|
| 170 |
+
const report = renderResearchReport(msg);
|
| 171 |
+
chatContainer.appendChild(report);
|
| 172 |
+
break;
|
| 173 |
+
|
| 174 |
+
case 'research-container':
|
| 175 |
+
const researchWidget = renderResearchContainer(msg);
|
| 176 |
+
chatContainer.appendChild(researchWidget);
|
| 177 |
+
break;
|
| 178 |
+
|
| 179 |
+
default:
|
| 180 |
+
// Legacy fallback: messages without type or with parts array
|
| 181 |
+
if (msg.parts && msg.parts.length > 0) {
|
| 182 |
+
// Old format with parts array
|
| 183 |
+
for (const part of msg.parts) {
|
| 184 |
+
switch (part.type) {
|
| 185 |
+
case 'text':
|
| 186 |
+
const oldTextDiv = document.createElement('div');
|
| 187 |
+
oldTextDiv.className = 'message assistant';
|
| 188 |
+
oldTextDiv.innerHTML = `<div class="message-content">${part.html || parseMarkdown(part.content || '')}</div>`;
|
| 189 |
+
chatContainer.appendChild(oldTextDiv);
|
| 190 |
+
break;
|
| 191 |
+
case 'action-widget':
|
| 192 |
+
chatContainer.appendChild(renderActionWidget(part));
|
| 193 |
+
break;
|
| 194 |
+
case 'code-cell':
|
| 195 |
+
chatContainer.appendChild(renderCodeCell(part));
|
| 196 |
+
break;
|
| 197 |
+
case 'result-preview':
|
| 198 |
+
chatContainer.appendChild(renderResultPreview(part));
|
| 199 |
+
break;
|
| 200 |
+
case 'agent-result':
|
| 201 |
+
chatContainer.appendChild(renderResearchReport(part));
|
| 202 |
+
break;
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
} else if (msg.contentHtml) {
|
| 206 |
+
const legacyMsg = document.createElement('div');
|
| 207 |
+
legacyMsg.className = 'message assistant';
|
| 208 |
+
legacyMsg.innerHTML = msg.contentHtml;
|
| 209 |
+
chatContainer.appendChild(legacyMsg);
|
| 210 |
+
} else if (msg.content) {
|
| 211 |
+
const contentMsg = document.createElement('div');
|
| 212 |
+
contentMsg.className = 'message assistant';
|
| 213 |
+
contentMsg.innerHTML = `<div class="message-content">${parseMarkdown(msg.content)}</div>`;
|
| 214 |
+
chatContainer.appendChild(contentMsg);
|
| 215 |
+
}
|
| 216 |
+
break;
|
| 217 |
+
}
|
| 218 |
+
} else if (msg.role === 'tool' && msg.type === 'tool-response') {
|
| 219 |
+
// Restore hidden tool-response element for getConversationHistory()
|
| 220 |
+
const trMsg = document.createElement('div');
|
| 221 |
+
trMsg.className = 'message tool';
|
| 222 |
+
trMsg.style.display = 'none';
|
| 223 |
+
trMsg.setAttribute('data-tool-response', JSON.stringify(msg.data));
|
| 224 |
+
chatContainer.appendChild(trMsg);
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// Helper functions to render saved message parts
|
| 230 |
+
|
| 231 |
+
function renderActionWidget(data) {
|
| 232 |
+
const widget = document.createElement('div');
|
| 233 |
+
widget.className = 'action-widget';
|
| 234 |
+
if (data.targetTabId) {
|
| 235 |
+
widget.dataset.targetTabId = data.targetTabId;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
const indicatorHtml = data.isDone
|
| 239 |
+
? '<div class="done-indicator"></div>'
|
| 240 |
+
: '<div class="orbit-indicator"><span></span><span></span><span></span></div>';
|
| 241 |
+
|
| 242 |
+
let bodyHtml = `
|
| 243 |
+
<div class="section-label">QUERY</div>
|
| 244 |
+
<div class="section-content">${parseMarkdown(data.query || '')}</div>
|
| 245 |
+
`;
|
| 246 |
+
|
| 247 |
+
// Add result section if present
|
| 248 |
+
if (data.resultHtml || data.result) {
|
| 249 |
+
bodyHtml += `
|
| 250 |
+
<div class="action-widget-result-section">
|
| 251 |
+
<div class="section-label" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-primary);">RESULT</div>
|
| 252 |
+
<div class="section-content">${data.resultHtml || escapeHtml(data.result || '')}</div>
|
| 253 |
+
</div>
|
| 254 |
+
`;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
widget.innerHTML = `
|
| 258 |
+
<div class="action-widget-clickable">
|
| 259 |
+
<div class="action-widget-header">
|
| 260 |
+
<div class="widget-collapse-toggle"></div>
|
| 261 |
+
<span class="action-widget-type">TASK: ${(data.actionType || '').toUpperCase()}</span>
|
| 262 |
+
<div class="action-widget-bar-right">
|
| 263 |
+
${indicatorHtml}
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
<div class="action-widget-body">
|
| 268 |
+
${bodyHtml}
|
| 269 |
+
</div>
|
| 270 |
+
`;
|
| 271 |
+
|
| 272 |
+
// Collapse toggle
|
| 273 |
+
const collapseToggle = widget.querySelector('.widget-collapse-toggle');
|
| 274 |
+
collapseToggle.addEventListener('click', (e) => {
|
| 275 |
+
e.stopPropagation();
|
| 276 |
+
widget.classList.toggle('collapsed');
|
| 277 |
+
collapseToggle.classList.toggle('collapsed');
|
| 278 |
+
});
|
| 279 |
+
|
| 280 |
+
// Make clickable if we have a target tab
|
| 281 |
+
if (data.targetTabId) {
|
| 282 |
+
const clickableArea = widget.querySelector('.action-widget-clickable');
|
| 283 |
+
clickableArea.addEventListener('click', () => {
|
| 284 |
+
const tabId = parseInt(data.targetTabId);
|
| 285 |
+
if (!isNaN(tabId)) {
|
| 286 |
+
// Check if the tab still exists (use .tab to avoid matching timeline elements)
|
| 287 |
+
const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
|
| 288 |
+
if (tab) {
|
| 289 |
+
// Tab exists, just switch to it
|
| 290 |
+
switchToTab(tabId);
|
| 291 |
+
} else {
|
| 292 |
+
// Tab was closed - restore from timeline data
|
| 293 |
+
const notebook = timelineData[tabId];
|
| 294 |
+
if (notebook) {
|
| 295 |
+
reopenClosedTab(tabId, notebook);
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
});
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
return widget;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
function renderCodeCell(data) {
|
| 306 |
+
const cell = document.createElement('div');
|
| 307 |
+
cell.className = 'code-cell';
|
| 308 |
+
|
| 309 |
+
let outputHtml = '';
|
| 310 |
+
const cleanedData = cleanCodeOutput(data.output);
|
| 311 |
+
if (data.outputHtml || cleanedData) {
|
| 312 |
+
const errorClass = data.isError ? ' error' : '';
|
| 313 |
+
outputHtml = `<div class="code-cell-output${errorClass}">${data.outputHtml || escapeHtml(cleanedData)}</div>`;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// Build images HTML
|
| 317 |
+
let imagesHtml = '';
|
| 318 |
+
if (data.images && data.images.length > 0) {
|
| 319 |
+
for (const img of data.images) {
|
| 320 |
+
let labelHtml = '';
|
| 321 |
+
if (img.name) {
|
| 322 |
+
labelHtml = `<div class="figure-label">${escapeHtml(img.name)}</div>`;
|
| 323 |
+
}
|
| 324 |
+
imagesHtml += `<div class="code-cell-image">${labelHtml}<img src="${img.src}" onclick="openImageModal(this.src)"></div>`;
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
cell.innerHTML = `
|
| 329 |
+
<div class="code-cell-label"><div class="widget-collapse-toggle"></div><span>Code</span></div>
|
| 330 |
+
<div class="code-cell-code">
|
| 331 |
+
<pre><code class="language-python">${escapeHtml(data.code || '')}</code></pre>
|
| 332 |
+
</div>
|
| 333 |
+
${outputHtml}
|
| 334 |
+
${imagesHtml}
|
| 335 |
+
`;
|
| 336 |
+
setupCollapseToggle(cell, '.code-cell-label');
|
| 337 |
+
|
| 338 |
+
return cell;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
function renderResultPreview(data) {
|
| 342 |
+
const preview = document.createElement('div');
|
| 343 |
+
preview.className = 'result-preview';
|
| 344 |
+
|
| 345 |
+
preview.innerHTML = `
|
| 346 |
+
<div class="result-preview-label">Result</div>
|
| 347 |
+
<div class="result-preview-content">${data.html || escapeHtml(data.content || '')}</div>
|
| 348 |
+
`;
|
| 349 |
+
linkifyFilePaths(preview);
|
| 350 |
+
|
| 351 |
+
return preview;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
function renderResearchReport(data) {
|
| 355 |
+
const report = document.createElement('div');
|
| 356 |
+
report.className = 'agent-result';
|
| 357 |
+
|
| 358 |
+
report.innerHTML = `
|
| 359 |
+
<div class="result-header">Result</div>
|
| 360 |
+
<div class="result-content">${data.html || parseMarkdown(data.content || '')}</div>
|
| 361 |
+
`;
|
| 362 |
+
linkifyFilePaths(report);
|
| 363 |
+
|
| 364 |
+
return report;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
function renderResearchContainer(data) {
|
| 368 |
+
const container = document.createElement('div');
|
| 369 |
+
container.className = 'research-container message assistant';
|
| 370 |
+
|
| 371 |
+
container.innerHTML = `
|
| 372 |
+
<div class="research-header">
|
| 373 |
+
<span class="research-header-title">RESEARCH</span>
|
| 374 |
+
</div>
|
| 375 |
+
<div class="research-body">${data.html || ''}</div>
|
| 376 |
+
`;
|
| 377 |
+
|
| 378 |
+
return container;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
function serializeWorkspace() {
|
| 382 |
+
const workspace = {
|
| 383 |
+
version: 1,
|
| 384 |
+
tabCounter: tabCounter,
|
| 385 |
+
activeTabId: activeTabId,
|
| 386 |
+
agentCounters: agentCounters,
|
| 387 |
+
tabs: [],
|
| 388 |
+
timelineData: serializeTimelineData(),
|
| 389 |
+
debugHistory: debugHistory
|
| 390 |
+
};
|
| 391 |
+
|
| 392 |
+
// Serialize command center (tab 0)
|
| 393 |
+
workspace.tabs.push(serializeTab(0, 'command-center'));
|
| 394 |
+
|
| 395 |
+
// Serialize all other tabs
|
| 396 |
+
const tabElements = document.querySelectorAll('#dynamicTabs .tab');
|
| 397 |
+
for (const tabEl of tabElements) {
|
| 398 |
+
const tabId = parseInt(tabEl.dataset.tabId);
|
| 399 |
+
const content = document.querySelector(`[data-content-id="${tabId}"]`);
|
| 400 |
+
if (content) {
|
| 401 |
+
const chatContainer = content.querySelector('.chat-container');
|
| 402 |
+
const agentType = chatContainer?.dataset.agentType || 'chat';
|
| 403 |
+
workspace.tabs.push(serializeTab(tabId, agentType));
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
return workspace;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
function serializeTimelineData() {
|
| 411 |
+
const serialized = {};
|
| 412 |
+
for (const [tabId, data] of Object.entries(timelineData)) {
|
| 413 |
+
// Clone but strip savedContent (large HTML, not needed for timeline)
|
| 414 |
+
serialized[tabId] = {
|
| 415 |
+
type: data.type,
|
| 416 |
+
title: data.title,
|
| 417 |
+
events: data.events,
|
| 418 |
+
parentTabId: data.parentTabId,
|
| 419 |
+
isGenerating: false,
|
| 420 |
+
isClosed: data.isClosed || false
|
| 421 |
+
};
|
| 422 |
+
}
|
| 423 |
+
return serialized;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
function serializeTab(tabId, type) {
|
| 427 |
+
const tabEl = document.querySelector(`[data-tab-id="${tabId}"]`);
|
| 428 |
+
const content = document.querySelector(`[data-content-id="${tabId}"]`);
|
| 429 |
+
|
| 430 |
+
const tabData = {
|
| 431 |
+
id: tabId,
|
| 432 |
+
type: type,
|
| 433 |
+
title: tabEl?.querySelector('.tab-title')?.textContent || getTypeLabel(type) || 'TAB',
|
| 434 |
+
messages: []
|
| 435 |
+
};
|
| 436 |
+
|
| 437 |
+
if (!content) return tabData;
|
| 438 |
+
|
| 439 |
+
const chatContainer = content.querySelector('.chat-container');
|
| 440 |
+
if (!chatContainer) return tabData;
|
| 441 |
+
|
| 442 |
+
// Iterate over ALL direct children of chatContainer in order
|
| 443 |
+
// Elements can be: .message.user, .message.assistant, .action-widget, .code-cell, .agent-result, .system-message, .welcome-message
|
| 444 |
+
for (const child of chatContainer.children) {
|
| 445 |
+
// Skip welcome message and system messages
|
| 446 |
+
if (child.classList.contains('welcome-message') || child.classList.contains('system-message')) {
|
| 447 |
+
continue;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
// User message
|
| 451 |
+
if (child.classList.contains('message') && child.classList.contains('user')) {
|
| 452 |
+
tabData.messages.push({
|
| 453 |
+
role: 'user',
|
| 454 |
+
content: child.textContent.trim()
|
| 455 |
+
});
|
| 456 |
+
continue;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
// Research container (the search tree widget) - check BEFORE generic assistant message
|
| 460 |
+
// because research-container also has .message.assistant classes
|
| 461 |
+
if (child.classList.contains('research-container')) {
|
| 462 |
+
const bodyEl = child.querySelector('.research-body');
|
| 463 |
+
tabData.messages.push({
|
| 464 |
+
role: 'assistant',
|
| 465 |
+
type: 'research-container',
|
| 466 |
+
html: bodyEl?.innerHTML || ''
|
| 467 |
+
});
|
| 468 |
+
continue;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
// Tool-call message (hidden, used for LLM conversation history)
|
| 472 |
+
if (child.classList.contains('message') && child.hasAttribute('data-tool-call')) {
|
| 473 |
+
try {
|
| 474 |
+
tabData.messages.push({
|
| 475 |
+
role: 'assistant',
|
| 476 |
+
type: 'tool-call',
|
| 477 |
+
data: JSON.parse(child.getAttribute('data-tool-call'))
|
| 478 |
+
});
|
| 479 |
+
} catch (e) { /* skip unparseable */ }
|
| 480 |
+
continue;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
// Tool-response message (hidden, used for LLM conversation history)
|
| 484 |
+
if (child.classList.contains('message') && (child.hasAttribute('data-tool-response') || child.classList.contains('tool'))) {
|
| 485 |
+
try {
|
| 486 |
+
tabData.messages.push({
|
| 487 |
+
role: 'tool',
|
| 488 |
+
type: 'tool-response',
|
| 489 |
+
data: JSON.parse(child.getAttribute('data-tool-response'))
|
| 490 |
+
});
|
| 491 |
+
} catch (e) { /* skip unparseable */ }
|
| 492 |
+
continue;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
// Assistant message (with .message-content inside)
|
| 496 |
+
if (child.classList.contains('message') && child.classList.contains('assistant')) {
|
| 497 |
+
const messageContent = child.querySelector('.message-content');
|
| 498 |
+
if (messageContent) {
|
| 499 |
+
tabData.messages.push({
|
| 500 |
+
role: 'assistant',
|
| 501 |
+
type: 'text',
|
| 502 |
+
content: messageContent.textContent.trim(),
|
| 503 |
+
html: messageContent.innerHTML
|
| 504 |
+
});
|
| 505 |
+
} else {
|
| 506 |
+
// Fallback to raw HTML
|
| 507 |
+
tabData.messages.push({
|
| 508 |
+
role: 'assistant',
|
| 509 |
+
type: 'text',
|
| 510 |
+
html: child.innerHTML
|
| 511 |
+
});
|
| 512 |
+
}
|
| 513 |
+
continue;
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
// Action widget (appended directly to chatContainer)
|
| 517 |
+
if (child.classList.contains('action-widget')) {
|
| 518 |
+
const widgetData = {
|
| 519 |
+
role: 'assistant',
|
| 520 |
+
type: 'action-widget',
|
| 521 |
+
targetTabId: child.dataset.targetTabId,
|
| 522 |
+
actionType: child.querySelector('.action-widget-type')?.textContent?.replace('TASK: ', '') || '',
|
| 523 |
+
query: child.querySelector('.action-widget-body .section-content')?.textContent?.trim() || '',
|
| 524 |
+
isDone: !!child.querySelector('.done-indicator'),
|
| 525 |
+
result: null,
|
| 526 |
+
resultHtml: null
|
| 527 |
+
};
|
| 528 |
+
|
| 529 |
+
// Extract result if present
|
| 530 |
+
const resultSection = child.querySelector('.action-widget-result-section');
|
| 531 |
+
if (resultSection) {
|
| 532 |
+
const resultContent = resultSection.querySelector('.section-content');
|
| 533 |
+
if (resultContent) {
|
| 534 |
+
widgetData.result = resultContent.textContent.trim();
|
| 535 |
+
widgetData.resultHtml = resultContent.innerHTML;
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
tabData.messages.push(widgetData);
|
| 540 |
+
continue;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
// Code cell (appended directly to chatContainer)
|
| 544 |
+
if (child.classList.contains('code-cell')) {
|
| 545 |
+
const codeEl = child.querySelector('pre code');
|
| 546 |
+
const outputEl = child.querySelector('.code-cell-output');
|
| 547 |
+
|
| 548 |
+
// Collect images from the code cell
|
| 549 |
+
const images = [];
|
| 550 |
+
const imageEls = child.querySelectorAll('.code-cell-image');
|
| 551 |
+
for (const imgDiv of imageEls) {
|
| 552 |
+
const imgEl = imgDiv.querySelector('img');
|
| 553 |
+
const labelEl = imgDiv.querySelector('.figure-label');
|
| 554 |
+
if (imgEl) {
|
| 555 |
+
images.push({
|
| 556 |
+
src: imgEl.src, // data URL with base64
|
| 557 |
+
name: labelEl?.textContent || ''
|
| 558 |
+
});
|
| 559 |
+
}
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
tabData.messages.push({
|
| 563 |
+
role: 'assistant',
|
| 564 |
+
type: 'code-cell',
|
| 565 |
+
code: codeEl?.textContent || '',
|
| 566 |
+
output: outputEl?.textContent || '',
|
| 567 |
+
outputHtml: outputEl?.innerHTML || '',
|
| 568 |
+
isError: outputEl?.classList.contains('error') || false,
|
| 569 |
+
images: images
|
| 570 |
+
});
|
| 571 |
+
continue;
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
// Tool cell (web agent / image agent tool calls)
|
| 575 |
+
if (child.classList.contains('tool-cell')) {
|
| 576 |
+
const labelEl = child.querySelector('.tool-cell-label span');
|
| 577 |
+
const inputEl = child.querySelector('.tool-cell-input');
|
| 578 |
+
const outputEl = child.querySelector('.tool-cell-output');
|
| 579 |
+
|
| 580 |
+
tabData.messages.push({
|
| 581 |
+
role: 'assistant',
|
| 582 |
+
type: 'tool-cell',
|
| 583 |
+
toolName: child.getAttribute('data-tool-name') || '',
|
| 584 |
+
label: labelEl?.textContent || '',
|
| 585 |
+
input: inputEl?.textContent || '',
|
| 586 |
+
outputHtml: outputEl?.innerHTML || '',
|
| 587 |
+
});
|
| 588 |
+
continue;
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
// Research report (appended directly to chatContainer)
|
| 592 |
+
if (child.classList.contains('agent-result')) {
|
| 593 |
+
const contentEl = child.querySelector('.result-content');
|
| 594 |
+
tabData.messages.push({
|
| 595 |
+
role: 'assistant',
|
| 596 |
+
type: 'agent-result',
|
| 597 |
+
content: contentEl?.textContent || '',
|
| 598 |
+
html: contentEl?.innerHTML || ''
|
| 599 |
+
});
|
| 600 |
+
continue;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
// Result preview
|
| 604 |
+
if (child.classList.contains('result-preview')) {
|
| 605 |
+
const contentEl = child.querySelector('.result-preview-content');
|
| 606 |
+
tabData.messages.push({
|
| 607 |
+
role: 'assistant',
|
| 608 |
+
type: 'result-preview',
|
| 609 |
+
content: contentEl?.textContent || '',
|
| 610 |
+
html: contentEl?.innerHTML || ''
|
| 611 |
+
});
|
| 612 |
+
continue;
|
| 613 |
+
}
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
return tabData;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
function saveWorkspaceDebounced() {
|
| 620 |
+
// Clear any pending save
|
| 621 |
+
if (saveWorkspaceTimer) {
|
| 622 |
+
clearTimeout(saveWorkspaceTimer);
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
// Schedule save in 500ms
|
| 626 |
+
saveWorkspaceTimer = setTimeout(async () => {
|
| 627 |
+
try {
|
| 628 |
+
const workspace = serializeWorkspace();
|
| 629 |
+
const response = await apiFetch('/api/workspace', {
|
| 630 |
+
method: 'POST',
|
| 631 |
+
headers: { 'Content-Type': 'application/json' },
|
| 632 |
+
body: JSON.stringify(workspace)
|
| 633 |
+
});
|
| 634 |
+
if (response.ok) {
|
| 635 |
+
console.log('Workspace saved');
|
| 636 |
+
}
|
| 637 |
+
} catch (e) {
|
| 638 |
+
console.error('Failed to save workspace:', e);
|
| 639 |
+
}
|
| 640 |
+
}, 500);
|
| 641 |
+
}
|