agentacta 1.3.4 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -79
- package/db.js +1 -0
- package/index.js +57 -3
- package/package.json +1 -1
- package/public/app.js +217 -33
- package/public/index.html +4 -0
- package/public/style.css +142 -2
package/README.md
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/agentacta)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
**Your
|
|
7
|
+
**Your agent did 1000s of things today. Can you find the 1 that broke prod?**
|
|
8
8
|
|
|
9
|
-
AgentActa is
|
|
9
|
+
AgentActa is a local audit trail and search engine for AI agent sessions.
|
|
10
|
+
|
|
11
|
+
It indexes messages, tool calls, file edits, searches, and decisions into a fast UI you can query in seconds.
|
|
10
12
|
|
|
11
13
|
One command. Zero config. Full visibility.
|
|
12
14
|
|
|
@@ -18,24 +20,25 @@ npx agentacta
|
|
|
18
20
|
<img src="screenshots/demo.gif" alt="AgentActa demo" width="800">
|
|
19
21
|
</p>
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
## Why this exists
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
Agents move fast. Your memory of what happened doesn’t.
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
When you need to answer “what changed, when, and why,” you’re usually scraping logs, scrolling transcripts, or asking the same assistant that forgot 20 minutes ago.
|
|
26
28
|
|
|
27
|
-
AgentActa gives you
|
|
29
|
+
AgentActa gives you one place to inspect the full trail.
|
|
28
30
|
|
|
29
|
-
## What
|
|
31
|
+
## What you get
|
|
30
32
|
|
|
31
|
-
🔍
|
|
32
|
-
📋
|
|
33
|
-
📅
|
|
34
|
-
📁
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
- 🔍 Full-text search across messages, tool calls, and results
|
|
34
|
+
- 📋 Session browser with summaries, token breakdowns, and model info
|
|
35
|
+
- 📅 Timeline view with live updates for today
|
|
36
|
+
- 📁 File activity across all indexed sessions
|
|
37
|
+
- 🌗 Light and dark themes
|
|
38
|
+
- 📊 Stats for sessions, messages, tools, and tokens
|
|
39
|
+
- ⚡ Live indexing via file watching
|
|
40
|
+
- 📱 Mobile-friendly UI
|
|
41
|
+
- 💡 Search suggestions based on real data
|
|
39
42
|
|
|
40
43
|
## Demo
|
|
41
44
|
|
|
@@ -51,62 +54,81 @@ https://github.com/mirajchokshi/agentacta/raw/main/screenshots/demo-final.mp4
|
|
|
51
54
|

|
|
52
55
|

|
|
53
56
|
|
|
54
|
-
## Quick
|
|
57
|
+
## Quick start
|
|
55
58
|
|
|
56
59
|
```bash
|
|
57
|
-
#
|
|
60
|
+
# run directly
|
|
58
61
|
npx agentacta
|
|
59
62
|
|
|
60
|
-
#
|
|
63
|
+
# or install globally
|
|
61
64
|
npm install -g agentacta
|
|
62
65
|
agentacta
|
|
63
66
|
```
|
|
64
67
|
|
|
65
|
-
Open `http://localhost:4003`
|
|
68
|
+
Open: `http://localhost:4003`
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
Auto-detected session paths:
|
|
68
71
|
- `~/.openclaw/agents/*/sessions/` (OpenClaw)
|
|
69
72
|
- `~/.claude/projects/*/` (Claude Code)
|
|
70
73
|
- `~/.codex/sessions/` (Codex CLI)
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
Custom path:
|
|
73
76
|
|
|
74
77
|
```bash
|
|
75
78
|
AGENTACTA_SESSIONS_PATH=/path/to/sessions agentacta
|
|
76
79
|
```
|
|
77
80
|
|
|
78
|
-
##
|
|
81
|
+
## Core features
|
|
79
82
|
|
|
80
83
|
### Search
|
|
81
|
-
|
|
84
|
+
|
|
85
|
+
SQLite FTS5 full-text search with filters for message type (messages, tool calls, results) and role (user, assistant).
|
|
86
|
+
|
|
87
|
+
Suggestions come from your own dataset: top tools, common topics, frequently touched files.
|
|
82
88
|
|
|
83
89
|
### Sessions
|
|
84
|
-
|
|
90
|
+
|
|
91
|
+
Browse indexed sessions with auto-generated summaries, token splits (input/output), and model details. Click into any session to see the full event history.
|
|
92
|
+
|
|
93
|
+
Session types get tagged so noisy categories are easier to spot (cron, sub-agent, heartbeat).
|
|
85
94
|
|
|
86
95
|
### Timeline
|
|
87
|
-
|
|
96
|
+
|
|
97
|
+
Pick a date, see everything that happened, newest first. Today's view updates live as new events come in.
|
|
88
98
|
|
|
89
99
|
### File Activity
|
|
90
|
-
|
|
100
|
+
|
|
101
|
+
See what files were touched, how often, and by which sessions.
|
|
102
|
+
|
|
103
|
+
Sort by recency, frequency, or session count. Filter by extension. Group by directory. Click any file to see which sessions touched it.
|
|
91
104
|
|
|
92
105
|
### Export
|
|
93
|
-
Download any session or search results as Markdown or JSON. Great for sharing, auditing, or archiving.
|
|
94
106
|
|
|
95
|
-
|
|
107
|
+
Export sessions or search results as Markdown or JSON.
|
|
96
108
|
|
|
97
|
-
|
|
109
|
+
Useful for handoffs, incident writeups, and audit archives.
|
|
98
110
|
|
|
99
|
-
|
|
111
|
+
## How it works
|
|
100
112
|
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
AgentActa parses JSONL session files (OpenClaw, Claude Code, Codex CLI), then indexes events into local SQLite with FTS5.
|
|
114
|
+
|
|
115
|
+
The UI is a single-page app served by a lightweight Node HTTP server.
|
|
116
|
+
|
|
117
|
+
No framework build pipeline. Minimal moving parts.
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
Session JSONL files -> SQLite + FTS5 index -> HTTP API -> Web UI
|
|
103
121
|
```
|
|
104
122
|
|
|
105
|
-
|
|
123
|
+
Everything stays on your machine.
|
|
106
124
|
|
|
107
125
|
## Configuration
|
|
108
126
|
|
|
109
|
-
On first run, AgentActa creates
|
|
127
|
+
On first run, AgentActa creates:
|
|
128
|
+
- `~/.config/agentacta/config.json`
|
|
129
|
+
- or `agentacta.config.json` in current directory (if present)
|
|
130
|
+
|
|
131
|
+
Default config:
|
|
110
132
|
|
|
111
133
|
```json
|
|
112
134
|
{
|
|
@@ -118,85 +140,82 @@ On first run, AgentActa creates a config file with sensible defaults at `~/.conf
|
|
|
118
140
|
}
|
|
119
141
|
```
|
|
120
142
|
|
|
121
|
-
### Storage
|
|
143
|
+
### Storage modes
|
|
122
144
|
|
|
123
|
-
-
|
|
124
|
-
-
|
|
145
|
+
- `reference` (default): index parsed events in SQLite, keep source JSONL on disk. Lightweight.
|
|
146
|
+
- `archive`: store full JSONL in SQLite. Sessions survive even if original files are deleted. Uses more disk.
|
|
125
147
|
|
|
126
|
-
### Environment
|
|
148
|
+
### Environment variables
|
|
127
149
|
|
|
128
150
|
| Variable | Default | Description |
|
|
129
151
|
|---|---|---|
|
|
130
152
|
| `PORT` | `4003` | Server port |
|
|
131
|
-
| `AGENTACTA_HOST` | `127.0.0.1` | Bind address
|
|
132
|
-
| `AGENTACTA_SESSIONS_PATH` |
|
|
133
|
-
| `AGENTACTA_DB_PATH` | `./agentacta.db` | Database
|
|
134
|
-
| `AGENTACTA_STORAGE` | `reference` |
|
|
135
|
-
| `AGENTACTA_PROJECT_ALIASES_JSON` | unset |
|
|
153
|
+
| `AGENTACTA_HOST` | `127.0.0.1` | Bind address |
|
|
154
|
+
| `AGENTACTA_SESSIONS_PATH` | auto-detected | Custom sessions directory |
|
|
155
|
+
| `AGENTACTA_DB_PATH` | `./agentacta.db` | Database path |
|
|
156
|
+
| `AGENTACTA_STORAGE` | `reference` | `reference` or `archive` |
|
|
157
|
+
| `AGENTACTA_PROJECT_ALIASES_JSON` | unset | Rename inferred project labels |
|
|
136
158
|
|
|
137
159
|
## API
|
|
138
160
|
|
|
139
|
-
AgentActa exposes a JSON API for programmatic access — useful for integrating search into your agent's workflow.
|
|
140
|
-
|
|
141
161
|
| Endpoint | Description |
|
|
142
162
|
|---|---|
|
|
143
|
-
| `GET /api/stats` |
|
|
144
|
-
| `GET /api/sessions` |
|
|
145
|
-
| `GET /api/sessions/:id` | Full session
|
|
146
|
-
| `GET /api/search?q=<query>` | Full-text search
|
|
147
|
-
| `GET /api/suggestions` |
|
|
148
|
-
| `GET /api/timeline?date=YYYY-MM-DD` |
|
|
149
|
-
| `GET /api/files` |
|
|
150
|
-
| `GET /api/export/session/:id?format=md` | Export session
|
|
163
|
+
| `GET /api/stats` | Session/message/tool/token totals |
|
|
164
|
+
| `GET /api/sessions` | Session list with metadata |
|
|
165
|
+
| `GET /api/sessions/:id` | Full session events |
|
|
166
|
+
| `GET /api/search?q=<query>` | Full-text search + filters |
|
|
167
|
+
| `GET /api/suggestions` | Search suggestions |
|
|
168
|
+
| `GET /api/timeline?date=YYYY-MM-DD` | Events for one day |
|
|
169
|
+
| `GET /api/files` | Touched-file inventory |
|
|
170
|
+
| `GET /api/export/session/:id?format=md` | Export one session |
|
|
171
|
+
| `GET /api/timeline/stream?after=<ts>` | SSE stream for live timeline updates |
|
|
172
|
+
| `POST /api/maintenance` | VACUUM + WAL checkpoint (returns size before/after) |
|
|
151
173
|
| `GET /api/export/search?q=<query>&format=md` | Export search results |
|
|
152
174
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
Your AI agent can query AgentActa for better recall:
|
|
175
|
+
Agent integration example:
|
|
156
176
|
|
|
157
177
|
```javascript
|
|
158
|
-
const
|
|
159
|
-
const data = await
|
|
160
|
-
// Agent now has context from past sessions
|
|
178
|
+
const res = await fetch('http://localhost:4003/api/search?q=deployment+issue&limit=5');
|
|
179
|
+
const data = await res.json();
|
|
161
180
|
```
|
|
162
181
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
Want to see what AgentActa looks like with data? Run with demo sessions:
|
|
182
|
+
## Demo mode
|
|
166
183
|
|
|
167
184
|
```bash
|
|
168
|
-
#
|
|
185
|
+
# seed demo data + run
|
|
169
186
|
npm run demo
|
|
170
187
|
|
|
171
|
-
#
|
|
188
|
+
# or split steps
|
|
172
189
|
node scripts/seed-demo.js
|
|
173
190
|
node index.js --demo
|
|
174
191
|
```
|
|
175
192
|
|
|
176
|
-
|
|
193
|
+
Demo mode creates 7 realistic sessions (weather app build path: scaffolding, API, frontend, debugging, deployment, tests, sub-agent task).
|
|
177
194
|
|
|
178
195
|
## Security
|
|
179
196
|
|
|
180
|
-
|
|
197
|
+
AgentActa binds to `127.0.0.1` by default.
|
|
181
198
|
|
|
182
|
-
|
|
199
|
+
If you expose it on a network, do it intentionally:
|
|
183
200
|
|
|
184
201
|
```bash
|
|
185
202
|
AGENTACTA_HOST=0.0.0.0 agentacta
|
|
186
203
|
```
|
|
187
204
|
|
|
188
|
-
|
|
205
|
+
**Important:** Session data can contain sensitive content (file snippets, API payloads, personal messages, tool args). There is no built-in auth yet, so only expose on trusted networks.
|
|
189
206
|
|
|
190
|
-
## Tech
|
|
207
|
+
## Tech stack
|
|
191
208
|
|
|
192
|
-
-
|
|
193
|
-
-
|
|
194
|
-
-
|
|
195
|
-
-
|
|
209
|
+
- Node.js (built-in `http`)
|
|
210
|
+
- `better-sqlite3` + SQLite FTS5
|
|
211
|
+
- Vanilla HTML/CSS/JS
|
|
212
|
+
- PWA support
|
|
196
213
|
|
|
197
214
|
## Privacy
|
|
198
215
|
|
|
199
|
-
|
|
216
|
+
No telemetry. No cloud sync. No external indexing service.
|
|
217
|
+
|
|
218
|
+
Your session history stays local.
|
|
200
219
|
|
|
201
220
|
## Compatibility
|
|
202
221
|
|
|
@@ -207,13 +226,15 @@ All data stays local. AgentActa runs entirely on your machine — no cloud servi
|
|
|
207
226
|
|
|
208
227
|
## Contributing
|
|
209
228
|
|
|
210
|
-
PRs welcome
|
|
229
|
+
PRs welcome.
|
|
230
|
+
|
|
231
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). If you’re adding a new agent format, start in `indexer.js`.
|
|
211
232
|
|
|
212
|
-
##
|
|
233
|
+
## Name
|
|
213
234
|
|
|
214
|
-
*Acta*
|
|
235
|
+
*Acta* is Latin for “things done.”
|
|
215
236
|
|
|
216
|
-
|
|
237
|
+
That’s the job here: keep a readable record of what your agents actually did.
|
|
217
238
|
|
|
218
239
|
## License
|
|
219
240
|
|
|
@@ -221,4 +242,4 @@ MIT
|
|
|
221
242
|
|
|
222
243
|
---
|
|
223
244
|
|
|
224
|
-
Built in Chicago by humans and agents
|
|
245
|
+
Built in Chicago by humans and agents.
|
package/db.js
CHANGED
|
@@ -54,6 +54,7 @@ function init(dbPath) {
|
|
|
54
54
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
55
55
|
);
|
|
56
56
|
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_start_time ON sessions(start_time DESC);
|
|
57
58
|
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
|
|
58
59
|
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
|
|
59
60
|
CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
|
package/index.js
CHANGED
|
@@ -428,13 +428,67 @@ const server = http.createServer((req, res) => {
|
|
|
428
428
|
const date = query.date || (() => { const n = new Date(); return `${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,'0')}-${String(n.getDate()).padStart(2,'0')}`; })();
|
|
429
429
|
const from = new Date(date + 'T00:00:00').toISOString();
|
|
430
430
|
const to = new Date(date + 'T23:59:59.999').toISOString();
|
|
431
|
+
const limit = Math.min(parseInt(query.limit || '100', 10) || 100, 500);
|
|
432
|
+
const offset = Math.max(parseInt(query.offset || '0', 10) || 0, 0);
|
|
431
433
|
const events = db.prepare(
|
|
432
434
|
`SELECT e.*, s.summary as session_summary FROM events e
|
|
433
435
|
JOIN sessions s ON s.id = e.session_id
|
|
434
436
|
WHERE e.timestamp >= ? AND e.timestamp <= ?
|
|
435
|
-
ORDER BY e.timestamp DESC
|
|
436
|
-
|
|
437
|
-
|
|
437
|
+
ORDER BY e.timestamp DESC
|
|
438
|
+
LIMIT ? OFFSET ?`
|
|
439
|
+
).all(from, to, limit, offset);
|
|
440
|
+
const total = db.prepare(
|
|
441
|
+
`SELECT COUNT(*) as c FROM events e
|
|
442
|
+
WHERE e.timestamp >= ? AND e.timestamp <= ?`
|
|
443
|
+
).get(from, to).c;
|
|
444
|
+
json(res, { date, events, total, limit, offset, hasMore: offset + events.length < total });
|
|
445
|
+
}
|
|
446
|
+
else if (pathname === '/api/timeline/stream') {
|
|
447
|
+
res.writeHead(200, {
|
|
448
|
+
'Content-Type': 'text/event-stream',
|
|
449
|
+
'Cache-Control': 'no-cache',
|
|
450
|
+
'Connection': 'keep-alive',
|
|
451
|
+
'X-Accel-Buffering': 'no'
|
|
452
|
+
});
|
|
453
|
+
res.write(': connected\n\n');
|
|
454
|
+
|
|
455
|
+
let lastTs = query.after || new Date().toISOString();
|
|
456
|
+
|
|
457
|
+
const onUpdate = () => {
|
|
458
|
+
try {
|
|
459
|
+
const rows = db.prepare(
|
|
460
|
+
`SELECT e.*, s.summary as session_summary FROM events e
|
|
461
|
+
JOIN sessions s ON s.id = e.session_id
|
|
462
|
+
WHERE e.timestamp > ?
|
|
463
|
+
ORDER BY e.timestamp ASC`
|
|
464
|
+
).all(lastTs);
|
|
465
|
+
if (rows.length) {
|
|
466
|
+
lastTs = rows[rows.length - 1].timestamp;
|
|
467
|
+
res.write(`id: ${lastTs}\ndata: ${JSON.stringify(rows)}\n\n`);
|
|
468
|
+
}
|
|
469
|
+
} catch (err) {
|
|
470
|
+
console.error('Timeline SSE error:', err.message);
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
sseEmitter.on('session-update', onUpdate);
|
|
475
|
+
|
|
476
|
+
const ping = setInterval(() => {
|
|
477
|
+
try { res.write(': ping\n\n'); } catch {}
|
|
478
|
+
}, 30000);
|
|
479
|
+
|
|
480
|
+
req.on('close', () => {
|
|
481
|
+
sseEmitter.off('session-update', onUpdate);
|
|
482
|
+
clearInterval(ping);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
else if (pathname === '/api/maintenance') {
|
|
486
|
+
if (req.method !== 'POST') return json(res, { error: 'Method not allowed' }, 405);
|
|
487
|
+
const sizeBefore = getDbSize();
|
|
488
|
+
db.pragma('wal_checkpoint(TRUNCATE)');
|
|
489
|
+
db.exec('VACUUM');
|
|
490
|
+
const sizeAfter = getDbSize();
|
|
491
|
+
json(res, { ok: true, sizeBefore, sizeAfter });
|
|
438
492
|
}
|
|
439
493
|
else if (pathname === '/api/files') {
|
|
440
494
|
const limit = parseInt(query.limit) || 100;
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -3,10 +3,31 @@ const $$ = (s, p = document) => [...p.querySelectorAll(s)];
|
|
|
3
3
|
const content = $('#content');
|
|
4
4
|
const API = '/api';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const THEME_KEY = 'agentacta-theme';
|
|
7
|
+
|
|
8
|
+
function applyTheme(theme) {
|
|
9
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
10
|
+
const meta = document.querySelector('meta[name="theme-color"]');
|
|
11
|
+
if (meta) meta.setAttribute('content', theme === 'light' ? '#f5f7fb' : '#0a0e1a');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function initTheme() {
|
|
15
|
+
const saved = localStorage.getItem(THEME_KEY);
|
|
16
|
+
const theme = saved === 'dark' || saved === 'light' ? saved : 'light';
|
|
17
|
+
applyTheme(theme);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toggleTheme() {
|
|
21
|
+
const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
|
22
|
+
const next = current === 'light' ? 'dark' : 'light';
|
|
23
|
+
localStorage.setItem(THEME_KEY, next);
|
|
24
|
+
applyTheme(next);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function api(path, options = {}) {
|
|
7
28
|
let res;
|
|
8
29
|
try {
|
|
9
|
-
res = await fetch(API + path);
|
|
30
|
+
res = await fetch(API + path, options);
|
|
10
31
|
} catch (err) {
|
|
11
32
|
// Network error (server down, offline, etc.)
|
|
12
33
|
return { _error: true, error: 'Network error' };
|
|
@@ -71,6 +92,26 @@ function escHtml(s) {
|
|
|
71
92
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
72
93
|
}
|
|
73
94
|
|
|
95
|
+
function fmtToolName(name) {
|
|
96
|
+
if (!name) return '';
|
|
97
|
+
// MCP tools: mcp__provider__action → mcp_provider_action
|
|
98
|
+
const mcp = name.match(/^mcp__(.+?)__(.+)$/);
|
|
99
|
+
if (mcp) {
|
|
100
|
+
const provider = mcp[1].replace(/__/g, '_');
|
|
101
|
+
const action = mcp[2].replace(/__/g, '_');
|
|
102
|
+
return `mcp_${provider}_${action}`;
|
|
103
|
+
}
|
|
104
|
+
return name;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fmtToolGroup(name) {
|
|
108
|
+
if (!name) return '';
|
|
109
|
+
// MCP tools: collapse to mcp_provider (no action)
|
|
110
|
+
const mcp = name.match(/^mcp__(.+?)__/);
|
|
111
|
+
if (mcp) return 'mcp_' + mcp[1].replace(/__/g, '_');
|
|
112
|
+
return name;
|
|
113
|
+
}
|
|
114
|
+
|
|
74
115
|
function truncate(s, n = 200) {
|
|
75
116
|
if (!s) return '';
|
|
76
117
|
return s.length > n ? s.slice(0, n) + '\u2026' : s;
|
|
@@ -95,6 +136,48 @@ function transitionView() {
|
|
|
95
136
|
content.classList.add('view-enter');
|
|
96
137
|
}
|
|
97
138
|
|
|
139
|
+
function skeletonLine(width = '100%', height = '12px') {
|
|
140
|
+
return `<div class="skeleton-line" style="width:${width};height:${height}"></div>`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function skeletonRows(count = 6, kind = 'event') {
|
|
144
|
+
if (kind === 'session') {
|
|
145
|
+
return Array.from({ length: count }).map(() => `
|
|
146
|
+
<div class="session-item skeleton-card">
|
|
147
|
+
<div class="skeleton-line" style="width:58%;height:12px"></div>
|
|
148
|
+
<div class="skeleton-line" style="width:90%;height:14px"></div>
|
|
149
|
+
<div class="skeleton-line" style="width:72%;height:14px"></div>
|
|
150
|
+
</div>
|
|
151
|
+
`).join('');
|
|
152
|
+
}
|
|
153
|
+
if (kind === 'stats') {
|
|
154
|
+
return Array.from({ length: count }).map(() => `
|
|
155
|
+
<div class="stat-card skeleton-card">
|
|
156
|
+
<div class="skeleton-line" style="width:44%;height:10px"></div>
|
|
157
|
+
<div class="skeleton-line" style="width:66%;height:28px;margin-top:8px"></div>
|
|
158
|
+
</div>
|
|
159
|
+
`).join('');
|
|
160
|
+
}
|
|
161
|
+
if (kind === 'file') {
|
|
162
|
+
return Array.from({ length: count }).map(() => `
|
|
163
|
+
<div class="file-item skeleton-card">
|
|
164
|
+
<div class="skeleton-line" style="width:62%;height:13px"></div>
|
|
165
|
+
<div class="skeleton-line" style="width:86%;height:12px;margin-top:10px"></div>
|
|
166
|
+
</div>
|
|
167
|
+
`).join('');
|
|
168
|
+
}
|
|
169
|
+
return Array.from({ length: count }).map(() => `
|
|
170
|
+
<div class="event-item skeleton-row">
|
|
171
|
+
<div class="skeleton-line" style="width:72px;height:10px"></div>
|
|
172
|
+
<div class="skeleton-line" style="width:60px;height:16px"></div>
|
|
173
|
+
<div class="event-body">
|
|
174
|
+
<div class="skeleton-line" style="width:82%;height:12px"></div>
|
|
175
|
+
<div class="skeleton-line" style="width:66%;height:12px;margin-top:8px"></div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
`).join('');
|
|
179
|
+
}
|
|
180
|
+
|
|
98
181
|
// --- Hash routing ---
|
|
99
182
|
window._navDepth = 0;
|
|
100
183
|
|
|
@@ -118,6 +201,7 @@ function updateNavActive(view) {
|
|
|
118
201
|
function handleRoute() {
|
|
119
202
|
const raw = (window.location.hash || '').slice(1) || 'search';
|
|
120
203
|
if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
|
|
204
|
+
if (window._timelineScrollHandler) { window.removeEventListener('scroll', window._timelineScrollHandler); window._timelineScrollHandler = null; }
|
|
121
205
|
|
|
122
206
|
if (raw.startsWith('session/')) {
|
|
123
207
|
const id = decodeURIComponent(raw.slice('session/'.length));
|
|
@@ -144,7 +228,7 @@ function renderEvent(ev) {
|
|
|
144
228
|
let body = '';
|
|
145
229
|
|
|
146
230
|
if (ev.type === 'tool_call') {
|
|
147
|
-
body = `<span class="tool-name">${escHtml(ev.tool_name)}</span>`;
|
|
231
|
+
body = `<span class="tool-name">${escHtml(fmtToolName(ev.tool_name))}</span>`;
|
|
148
232
|
if (ev.tool_args) {
|
|
149
233
|
try {
|
|
150
234
|
const args = JSON.parse(ev.tool_args);
|
|
@@ -154,7 +238,7 @@ function renderEvent(ev) {
|
|
|
154
238
|
}
|
|
155
239
|
}
|
|
156
240
|
} else if (ev.type === 'tool_result') {
|
|
157
|
-
body = `<span class="tool-name">\u2192 ${escHtml(ev.tool_name)}</span>`;
|
|
241
|
+
body = `<span class="tool-name">\u2192 ${escHtml(fmtToolName(ev.tool_name))}</span>`;
|
|
158
242
|
if (ev.content) {
|
|
159
243
|
body += `<div class="tool-args">${escHtml(truncate(ev.content, 500))}</div>`;
|
|
160
244
|
}
|
|
@@ -178,7 +262,7 @@ function renderTimelineEvent(ev) {
|
|
|
178
262
|
let body = '';
|
|
179
263
|
|
|
180
264
|
if (ev.type === 'tool_call') {
|
|
181
|
-
body = `<span class="tool-name">${escHtml(ev.tool_name)}</span>`;
|
|
265
|
+
body = `<span class="tool-name">${escHtml(fmtToolName(ev.tool_name))}</span>`;
|
|
182
266
|
if (ev.tool_args) {
|
|
183
267
|
try {
|
|
184
268
|
const args = JSON.parse(ev.tool_args);
|
|
@@ -188,7 +272,7 @@ function renderTimelineEvent(ev) {
|
|
|
188
272
|
}
|
|
189
273
|
}
|
|
190
274
|
} else if (ev.type === 'tool_result') {
|
|
191
|
-
body = `<span class="tool-name">\u2192 ${escHtml(ev.tool_name)}</span>`;
|
|
275
|
+
body = `<span class="tool-name">\u2192 ${escHtml(fmtToolName(ev.tool_name))}</span>`;
|
|
192
276
|
if (ev.content) {
|
|
193
277
|
body += `<div class="tool-args">${escHtml(truncate(ev.content, 500))}</div>`;
|
|
194
278
|
}
|
|
@@ -296,7 +380,12 @@ async function viewSearch(query = '') {
|
|
|
296
380
|
<span class="filter-chip ${roleFilter==='user'?'active':''}" data-filter="role" data-val="user">User</span>
|
|
297
381
|
<span class="filter-chip ${roleFilter==='assistant'?'active':''}" data-filter="role" data-val="assistant">Assistant</span>
|
|
298
382
|
</div>
|
|
299
|
-
<div id="results"
|
|
383
|
+
<div id="results">
|
|
384
|
+
<div class="search-bar skeleton-card" style="margin-top:6px">
|
|
385
|
+
<div class="skeleton-line" style="height:16px;width:40%"></div>
|
|
386
|
+
</div>
|
|
387
|
+
${skeletonRows(4, 'session')}
|
|
388
|
+
</div>`;
|
|
300
389
|
|
|
301
390
|
content.innerHTML = html;
|
|
302
391
|
transitionView();
|
|
@@ -325,7 +414,7 @@ async function viewSearch(query = '') {
|
|
|
325
414
|
|
|
326
415
|
async function showSearchHome() {
|
|
327
416
|
const el = $('#results');
|
|
328
|
-
el.innerHTML =
|
|
417
|
+
el.innerHTML = `${skeletonRows(4, 'session')}`;
|
|
329
418
|
|
|
330
419
|
const stats = await api('/stats');
|
|
331
420
|
const sessions = await api('/sessions?limit=5');
|
|
@@ -376,7 +465,7 @@ async function doSearch(q) {
|
|
|
376
465
|
const el = $('#results');
|
|
377
466
|
if (!q.trim()) { el.innerHTML = '<div class="empty"><h2>Type to search</h2><p>Search across all sessions, messages, and tool calls</p></div>'; return; }
|
|
378
467
|
|
|
379
|
-
el.innerHTML =
|
|
468
|
+
el.innerHTML = `${skeletonRows(6, 'event')}`;
|
|
380
469
|
|
|
381
470
|
const type = window._searchType || '';
|
|
382
471
|
const role = window._searchRole || '';
|
|
@@ -402,7 +491,7 @@ async function doSearch(q) {
|
|
|
402
491
|
<div class="result-meta">
|
|
403
492
|
<span class="event-badge ${badgeClass(r.type, r.role)}">${r.type === 'tool_call' ? 'tool' : r.role || r.type}</span>
|
|
404
493
|
<span class="session-time">${fmtTime(r.timestamp)}</span>
|
|
405
|
-
${r.tool_name ? `<span class="tool-name">${escHtml(r.tool_name)}</span>` : ''}
|
|
494
|
+
${r.tool_name ? `<span class="tool-name">${escHtml(fmtToolName(r.tool_name))}</span>` : ''}
|
|
406
495
|
<span class="session-link" data-session="${r.session_id}">view session \u2192</span>
|
|
407
496
|
</div>
|
|
408
497
|
<div class="result-content">${escHtml(truncate(r.content || r.tool_args || r.tool_result || '', 400))}</div>
|
|
@@ -420,7 +509,8 @@ async function doSearch(q) {
|
|
|
420
509
|
|
|
421
510
|
async function viewSessions() {
|
|
422
511
|
window._currentSessionId = null;
|
|
423
|
-
content.innerHTML =
|
|
512
|
+
content.innerHTML = `<div class="page-title">Sessions</div>${skeletonRows(4, 'session')}`;
|
|
513
|
+
transitionView();
|
|
424
514
|
const data = await api('/sessions?limit=200');
|
|
425
515
|
if (data._error) {
|
|
426
516
|
content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
|
|
@@ -442,6 +532,12 @@ async function viewSession(id) {
|
|
|
442
532
|
window._currentSessionId = id;
|
|
443
533
|
setHash('session/' + encodeURIComponent(id));
|
|
444
534
|
window.scrollTo(0, 0);
|
|
535
|
+
content.innerHTML = `
|
|
536
|
+
<div class="back-btn">← Back</div>
|
|
537
|
+
<div class="page-title">Session</div>
|
|
538
|
+
${skeletonRows(8, 'event')}
|
|
539
|
+
`;
|
|
540
|
+
transitionView();
|
|
445
541
|
const data = await api(`/sessions/${id}`);
|
|
446
542
|
|
|
447
543
|
if (data._error || data.error) { content.innerHTML = `<div class="empty"><h2>${escHtml(data.error || 'Unable to load')}</h2></div>`; return; }
|
|
@@ -674,34 +770,93 @@ async function viewTimeline(date) {
|
|
|
674
770
|
date = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
|
|
675
771
|
}
|
|
676
772
|
window._lastView = 'timeline';
|
|
773
|
+
window._timelineState = { date, limit: 100, offset: 0, hasMore: true, loading: false };
|
|
677
774
|
|
|
678
|
-
|
|
775
|
+
content.innerHTML = `<div class="page-title">Timeline</div>
|
|
679
776
|
<input type="date" class="date-input" id="dateInput" value="${date}">
|
|
680
|
-
<div id="timelineContent"
|
|
681
|
-
|
|
777
|
+
<div id="timelineContent">${skeletonRows(8, 'event')}</div>
|
|
778
|
+
<div id="timelineLoadMore" class="loading-more" style="display:none">Loading more…</div>`;
|
|
682
779
|
transitionView();
|
|
683
780
|
|
|
684
|
-
const data = await api(`/timeline?date=${date}`);
|
|
685
|
-
if (data._error) {
|
|
686
|
-
$('#timelineContent').innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
781
|
const el = $('#timelineContent');
|
|
782
|
+
const state = window._timelineState;
|
|
690
783
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
784
|
+
async function loadTimelinePage(append = false) {
|
|
785
|
+
if (state.loading || (!state.hasMore && append)) return;
|
|
786
|
+
state.loading = true;
|
|
787
|
+
if (append) $('#timelineLoadMore').style.display = 'block';
|
|
788
|
+
|
|
789
|
+
const data = await api(`/timeline?date=${state.date}&limit=${state.limit}&offset=${state.offset}`);
|
|
790
|
+
state.loading = false;
|
|
791
|
+
$('#timelineLoadMore').style.display = 'none';
|
|
792
|
+
|
|
793
|
+
if (data._error) {
|
|
794
|
+
if (!append) el.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
state.hasMore = !!data.hasMore;
|
|
799
|
+
state.offset += (data.events || []).length;
|
|
800
|
+
|
|
801
|
+
if (!append) {
|
|
802
|
+
if (!data.events.length) {
|
|
803
|
+
el.innerHTML = '<div class="empty"><h2>No activity</h2><p>Nothing recorded on this day</p></div>';
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
el.innerHTML = `<div class="timeline-events-wrap" id="timelineWrap"><div class="timeline-line"></div>${data.events.map(renderTimelineEvent).join('')}</div>`;
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const wrap = $('#timelineWrap');
|
|
811
|
+
if (wrap) {
|
|
812
|
+
wrap.insertAdjacentHTML('beforeend', data.events.map(renderTimelineEvent).join(''));
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
await loadTimelinePage(false);
|
|
817
|
+
|
|
818
|
+
if (window._timelineScrollHandler) window.removeEventListener('scroll', window._timelineScrollHandler);
|
|
819
|
+
window._timelineScrollHandler = () => {
|
|
820
|
+
const st = window._timelineState;
|
|
821
|
+
if (!st || st.loading || !st.hasMore) return;
|
|
822
|
+
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
|
|
823
|
+
if (nearBottom) loadTimelinePage(true);
|
|
824
|
+
};
|
|
825
|
+
window.addEventListener('scroll', window._timelineScrollHandler, { passive: true });
|
|
826
|
+
|
|
827
|
+
// Live updates via SSE (only for today)
|
|
828
|
+
const today = new Date();
|
|
829
|
+
const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
|
|
830
|
+
if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
|
|
831
|
+
if (date === todayStr) {
|
|
832
|
+
const sse = new EventSource(`/api/timeline/stream?after=${encodeURIComponent(new Date().toISOString())}`);
|
|
833
|
+
window._timelineSse = sse;
|
|
834
|
+
sse.onmessage = (evt) => {
|
|
835
|
+
try {
|
|
836
|
+
const rows = JSON.parse(evt.data);
|
|
837
|
+
const wrap = $('#timelineWrap');
|
|
838
|
+
if (wrap && rows.length) {
|
|
839
|
+
const html = rows.map(renderTimelineEvent).join('');
|
|
840
|
+
wrap.insertAdjacentHTML('afterbegin', html);
|
|
841
|
+
// Flash new events
|
|
842
|
+
rows.forEach(r => {
|
|
843
|
+
const el = wrap.querySelector(`[data-event-id="${r.id}"]`);
|
|
844
|
+
if (el) { el.classList.add('event-highlight'); setTimeout(() => el.classList.remove('event-highlight'), 2000); }
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
} catch {}
|
|
848
|
+
};
|
|
698
849
|
}
|
|
699
850
|
|
|
700
|
-
$('#dateInput').addEventListener('change', e =>
|
|
851
|
+
$('#dateInput').addEventListener('change', e => {
|
|
852
|
+
if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
|
|
853
|
+
viewTimeline(e.target.value);
|
|
854
|
+
});
|
|
701
855
|
}
|
|
702
856
|
|
|
703
857
|
async function viewStats() {
|
|
704
|
-
content.innerHTML =
|
|
858
|
+
content.innerHTML = `<div class="page-title">Stats</div><div class="stat-grid">${skeletonRows(5, 'stats')}</div>`;
|
|
859
|
+
transitionView();
|
|
705
860
|
const data = await api('/stats');
|
|
706
861
|
if (data._error) {
|
|
707
862
|
content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
|
|
@@ -713,14 +868,19 @@ async function viewStats() {
|
|
|
713
868
|
<div class="stat-card accent-blue"><div class="label">Sessions</div><div class="value">${data.sessions}</div></div>
|
|
714
869
|
<div class="stat-card accent-green"><div class="label">Messages</div><div class="value">${data.messages.toLocaleString()}</div></div>
|
|
715
870
|
<div class="stat-card accent-amber"><div class="label">Tool Calls</div><div class="value">${data.toolCalls.toLocaleString()}</div></div>
|
|
716
|
-
<div class="stat-card accent-purple"><div class="label">Unique Tools</div><div class="value">${data.
|
|
871
|
+
<div class="stat-card accent-purple"><div class="label">Unique Tools</div><div class="value">${new Set((data.tools||[]).filter(t=>t).map(t=>fmtToolGroup(t))).size}</div></div>
|
|
717
872
|
<div class="stat-card accent-teal"><div class="label">Total Tokens</div><div class="value">${(data.totalTokens || 0).toLocaleString()}</div></div>
|
|
718
873
|
</div>
|
|
719
874
|
|
|
720
875
|
<div class="section-label">Configuration</div>
|
|
721
|
-
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:var(--space-md);margin-bottom:var(--space-
|
|
876
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:var(--space-md);margin-bottom:var(--space-md)">
|
|
722
877
|
<div class="config-card"><div class="config-label">Storage Mode</div><div class="config-value">${escHtml(data.storageMode || 'reference')}</div></div>
|
|
723
|
-
<div class="config-card"><div class="config-label">DB Size</div><div class="config-value">${escHtml(data.dbSize?.display || 'N/A')}</div></div>
|
|
878
|
+
<div class="config-card"><div class="config-label">DB Size</div><div class="config-value" id="dbSizeValue">${escHtml(data.dbSize?.display || 'N/A')}</div></div>
|
|
879
|
+
</div>
|
|
880
|
+
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:var(--space-xl)">
|
|
881
|
+
<button class="export-btn" id="optimizeDbBtn">Optimize Database</button>
|
|
882
|
+
<span style="color:var(--text-tertiary);font-size:12px;line-height:1.5">Reclaims unused space and merges pending writes. Safe to run anytime, doesn't delete any data.</span>
|
|
883
|
+
<span id="optimizeDbStatus" style="color:var(--text-tertiary);font-size:12px"></span>
|
|
724
884
|
</div>
|
|
725
885
|
|
|
726
886
|
${data.sessionDirs && data.sessionDirs.length ? (() => {
|
|
@@ -755,16 +915,35 @@ async function viewStats() {
|
|
|
755
915
|
<div class="section-label">Date Range</div>
|
|
756
916
|
<p style="color:var(--text-secondary);font-size:13px;margin-bottom:var(--space-xl)">${fmtDate(data.dateRange?.earliest)} \u2014 ${fmtDate(data.dateRange?.latest)}</p>
|
|
757
917
|
<div class="section-label">Tools Used</div>
|
|
758
|
-
<div class="tools-grid">${(data.tools||[]).filter(t => t).sort().map(t => `<span class="tool-chip">${escHtml(t)}</span>`).join('')}</div>
|
|
918
|
+
<div class="tools-grid">${[...new Set((data.tools||[]).filter(t => t).map(t => fmtToolGroup(t)))].sort().map(t => `<span class="tool-chip">${escHtml(t)}</span>`).join('')}</div>
|
|
759
919
|
`;
|
|
760
920
|
|
|
761
921
|
content.innerHTML = html;
|
|
762
922
|
transitionView();
|
|
923
|
+
|
|
924
|
+
const optimizeBtn = $('#optimizeDbBtn');
|
|
925
|
+
const optimizeStatus = $('#optimizeDbStatus');
|
|
926
|
+
if (optimizeBtn) {
|
|
927
|
+
optimizeBtn.addEventListener('click', async () => {
|
|
928
|
+
optimizeBtn.disabled = true;
|
|
929
|
+
optimizeStatus.textContent = 'Optimizing…';
|
|
930
|
+
const result = await api('/maintenance', { method: 'POST' });
|
|
931
|
+
if (result._error || !result.ok) {
|
|
932
|
+
optimizeStatus.textContent = `Failed: ${result.error || 'Unknown error'}`;
|
|
933
|
+
} else {
|
|
934
|
+
optimizeStatus.textContent = `${result.sizeBefore?.display || 'N/A'} → ${result.sizeAfter?.display || 'N/A'}`;
|
|
935
|
+
const dbSizeValue = $('#dbSizeValue');
|
|
936
|
+
if (dbSizeValue) dbSizeValue.textContent = result.sizeAfter?.display || 'N/A';
|
|
937
|
+
}
|
|
938
|
+
optimizeBtn.disabled = false;
|
|
939
|
+
});
|
|
940
|
+
}
|
|
763
941
|
}
|
|
764
942
|
|
|
765
943
|
async function viewFiles() {
|
|
766
944
|
window._lastView = 'files';
|
|
767
|
-
content.innerHTML =
|
|
945
|
+
content.innerHTML = `<div class="page-title">Files</div>${skeletonRows(6, 'file')}`;
|
|
946
|
+
transitionView();
|
|
768
947
|
const data = await api('/files?limit=500');
|
|
769
948
|
if (data._error) {
|
|
770
949
|
content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
|
|
@@ -964,6 +1143,8 @@ window._lastView = 'sessions';
|
|
|
964
1143
|
$$('.nav-item').forEach(item => {
|
|
965
1144
|
item.addEventListener('click', () => {
|
|
966
1145
|
if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
|
|
1146
|
+
if (window._timelineScrollHandler) { window.removeEventListener('scroll', window._timelineScrollHandler); window._timelineScrollHandler = null; }
|
|
1147
|
+
if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
|
|
967
1148
|
const view = item.dataset.view;
|
|
968
1149
|
window._lastView = view;
|
|
969
1150
|
updateNavActive(view);
|
|
@@ -976,6 +1157,9 @@ $$('.nav-item').forEach(item => {
|
|
|
976
1157
|
});
|
|
977
1158
|
});
|
|
978
1159
|
|
|
1160
|
+
initTheme();
|
|
1161
|
+
document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
|
|
1162
|
+
document.getElementById('theme-toggle-mobile')?.addEventListener('click', toggleTheme);
|
|
979
1163
|
handleRoute();
|
|
980
1164
|
|
|
981
1165
|
// Swipe right from left edge to go back
|
package/public/index.html
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
<nav class="sidebar">
|
|
22
22
|
<div class="sidebar-header">
|
|
23
23
|
<h1>Agent<span>Acta</span></h1>
|
|
24
|
+
<button class="theme-toggle" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme"><svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg><svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
|
|
24
25
|
</div>
|
|
25
26
|
<div class="nav-section">
|
|
26
27
|
<div class="nav-item" data-view="sessions">
|
|
@@ -43,9 +44,12 @@
|
|
|
43
44
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20V10M12 20V4M6 20v-6"/></svg>
|
|
44
45
|
<span>Stats</span>
|
|
45
46
|
</div>
|
|
47
|
+
|
|
46
48
|
</div>
|
|
47
49
|
</nav>
|
|
48
50
|
<main class="main" id="content"></main>
|
|
51
|
+
<button class="theme-toggle-mobile" id="theme-toggle-mobile" title="Toggle theme" aria-label="Toggle theme"><svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg><svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
|
|
52
|
+
|
|
49
53
|
</div>
|
|
50
54
|
<script src="/app.js"></script>
|
|
51
55
|
<script>
|
package/public/style.css
CHANGED
|
@@ -71,6 +71,47 @@
|
|
|
71
71
|
|
|
72
72
|
/* Layout */
|
|
73
73
|
--sidebar-width: 240px;
|
|
74
|
+
--input-scheme: dark;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
[data-theme="light"] {
|
|
78
|
+
--bg-base: #f5f7fb;
|
|
79
|
+
--bg-surface: #ffffff;
|
|
80
|
+
--bg-elevated: #f8fafc;
|
|
81
|
+
--bg-hover: #eef2f7;
|
|
82
|
+
--bg-active: #e8edf6;
|
|
83
|
+
|
|
84
|
+
--border-subtle: rgba(15, 23, 42, 0.06);
|
|
85
|
+
--border-default: rgba(15, 23, 42, 0.1);
|
|
86
|
+
--border-hover: rgba(15, 23, 42, 0.16);
|
|
87
|
+
--border-focus: rgba(36, 89, 214, 0.45);
|
|
88
|
+
|
|
89
|
+
--text-primary: #111827;
|
|
90
|
+
--text-secondary: #5b6678;
|
|
91
|
+
--text-tertiary: #8793a7;
|
|
92
|
+
--text-inverse: #ffffff;
|
|
93
|
+
|
|
94
|
+
--accent: #2459d6;
|
|
95
|
+
--accent-soft: rgba(36, 89, 214, 0.12);
|
|
96
|
+
--accent-medium: rgba(36, 89, 214, 0.2);
|
|
97
|
+
|
|
98
|
+
--green: #158f69;
|
|
99
|
+
--green-soft: rgba(21, 143, 105, 0.12);
|
|
100
|
+
|
|
101
|
+
--purple: #7d62eb;
|
|
102
|
+
--purple-soft: rgba(125, 98, 235, 0.12);
|
|
103
|
+
|
|
104
|
+
--amber: #b7791f;
|
|
105
|
+
--amber-soft: rgba(183, 121, 31, 0.12);
|
|
106
|
+
|
|
107
|
+
--red: #c24141;
|
|
108
|
+
--red-soft: rgba(194, 65, 65, 0.12);
|
|
109
|
+
|
|
110
|
+
--teal: #0f9f9a;
|
|
111
|
+
--teal-soft: rgba(15, 159, 154, 0.12);
|
|
112
|
+
|
|
113
|
+
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
|
114
|
+
--input-scheme: light;
|
|
74
115
|
}
|
|
75
116
|
|
|
76
117
|
/* ---- Reset ---- */
|
|
@@ -125,6 +166,10 @@ body {
|
|
|
125
166
|
|
|
126
167
|
.sidebar-header {
|
|
127
168
|
padding: var(--space-xl) var(--space-xl) var(--space-lg);
|
|
169
|
+
display: flex;
|
|
170
|
+
align-items: center;
|
|
171
|
+
justify-content: space-between;
|
|
172
|
+
gap: var(--space-sm);
|
|
128
173
|
}
|
|
129
174
|
|
|
130
175
|
.sidebar h1 {
|
|
@@ -141,6 +186,63 @@ body {
|
|
|
141
186
|
background-clip: text;
|
|
142
187
|
}
|
|
143
188
|
|
|
189
|
+
.theme-toggle {
|
|
190
|
+
width: 30px;
|
|
191
|
+
height: 30px;
|
|
192
|
+
border-radius: var(--radius-md);
|
|
193
|
+
border: 1px solid var(--border-default);
|
|
194
|
+
background: var(--bg-elevated);
|
|
195
|
+
color: var(--text-secondary);
|
|
196
|
+
display: inline-flex;
|
|
197
|
+
align-items: center;
|
|
198
|
+
justify-content: center;
|
|
199
|
+
cursor: pointer;
|
|
200
|
+
padding: 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.theme-toggle:hover {
|
|
204
|
+
background: var(--bg-hover);
|
|
205
|
+
color: var(--text-primary);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.theme-toggle svg,
|
|
209
|
+
.theme-toggle-mobile svg {
|
|
210
|
+
width: 16px;
|
|
211
|
+
height: 16px;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.theme-icon-moon { display: none; }
|
|
215
|
+
[data-theme="light"] .theme-icon-sun { display: none; }
|
|
216
|
+
[data-theme="light"] .theme-icon-moon { display: block; }
|
|
217
|
+
|
|
218
|
+
.theme-toggle-mobile {
|
|
219
|
+
display: none;
|
|
220
|
+
width: 32px;
|
|
221
|
+
height: 32px;
|
|
222
|
+
border-radius: var(--radius-md);
|
|
223
|
+
border: 1px solid var(--border-default);
|
|
224
|
+
background: var(--bg-surface);
|
|
225
|
+
color: var(--text-secondary);
|
|
226
|
+
align-items: center;
|
|
227
|
+
justify-content: center;
|
|
228
|
+
cursor: pointer;
|
|
229
|
+
padding: 0;
|
|
230
|
+
position: absolute;
|
|
231
|
+
top: calc(var(--space-xl) + env(safe-area-inset-top, 0px));
|
|
232
|
+
right: var(--space-lg);
|
|
233
|
+
z-index: 10;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.theme-toggle-mobile:hover {
|
|
237
|
+
background: var(--bg-hover);
|
|
238
|
+
color: var(--text-primary);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.theme-toggle-mobile svg {
|
|
242
|
+
width: 16px;
|
|
243
|
+
height: 16px;
|
|
244
|
+
}
|
|
245
|
+
|
|
144
246
|
.nav-section {
|
|
145
247
|
display: flex;
|
|
146
248
|
flex-direction: column;
|
|
@@ -203,7 +305,7 @@ body {
|
|
|
203
305
|
flex: 1;
|
|
204
306
|
margin-left: var(--sidebar-width);
|
|
205
307
|
padding: var(--space-2xl) var(--space-3xl);
|
|
206
|
-
max-width:
|
|
308
|
+
max-width: none;
|
|
207
309
|
min-height: 100vh;
|
|
208
310
|
}
|
|
209
311
|
|
|
@@ -655,6 +757,40 @@ mark {
|
|
|
655
757
|
font-weight: 500;
|
|
656
758
|
}
|
|
657
759
|
|
|
760
|
+
/* ---- Skeletons ---- */
|
|
761
|
+
.skeleton-card,
|
|
762
|
+
.skeleton-row {
|
|
763
|
+
border-color: var(--border-subtle);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.skeleton-line {
|
|
767
|
+
position: relative;
|
|
768
|
+
border-radius: var(--radius-sm);
|
|
769
|
+
background: var(--bg-elevated);
|
|
770
|
+
border: 1px solid var(--border-subtle);
|
|
771
|
+
overflow: hidden;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.skeleton-line::after {
|
|
775
|
+
content: '';
|
|
776
|
+
position: absolute;
|
|
777
|
+
inset: 0;
|
|
778
|
+
transform: translateX(-100%);
|
|
779
|
+
background: linear-gradient(90deg, transparent, var(--bg-hover), transparent);
|
|
780
|
+
animation: skeletonShimmer 1.4s ease-in-out infinite;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
@keyframes skeletonShimmer {
|
|
784
|
+
100% { transform: translateX(100%); }
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
.loading-more {
|
|
788
|
+
text-align: center;
|
|
789
|
+
color: var(--text-tertiary);
|
|
790
|
+
font-size: 12px;
|
|
791
|
+
padding: var(--space-md) 0;
|
|
792
|
+
}
|
|
793
|
+
|
|
658
794
|
@keyframes pulse {
|
|
659
795
|
0%, 100% { opacity: 0.4; }
|
|
660
796
|
50% { opacity: 1; }
|
|
@@ -683,7 +819,7 @@ mark {
|
|
|
683
819
|
outline: none;
|
|
684
820
|
margin-bottom: var(--space-lg);
|
|
685
821
|
transition: all var(--duration-normal) var(--ease-out);
|
|
686
|
-
color-scheme:
|
|
822
|
+
color-scheme: var(--input-scheme);
|
|
687
823
|
}
|
|
688
824
|
|
|
689
825
|
.date-input:hover {
|
|
@@ -1230,6 +1366,10 @@ mark {
|
|
|
1230
1366
|
|
|
1231
1367
|
.sidebar-header { display: none; }
|
|
1232
1368
|
|
|
1369
|
+
.theme-toggle-mobile {
|
|
1370
|
+
display: flex;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1233
1373
|
.nav-section {
|
|
1234
1374
|
display: flex;
|
|
1235
1375
|
flex-direction: row;
|