Javier commited on
Commit
d725335
·
1 Parent(s): 60d42dc

UI Functional, next fixing automations

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +5 -0
  2. app.py +1324 -0
  3. examples/feed-cat.yaml +10 -0
  4. examples/turn-on-pc.yaml +12 -0
  5. frontend/.gitignore +4 -0
  6. frontend/index.html +24 -0
  7. frontend/package.json +41 -0
  8. frontend/pnpm-lock.yaml +0 -0
  9. frontend/pnpm-workspace.yaml +4 -0
  10. frontend/src/App.tsx +43 -0
  11. frontend/src/components/layout/ModulePanel.tsx +40 -0
  12. frontend/src/components/layout/Section.tsx +32 -0
  13. frontend/src/components/layout/Sidebar.tsx +54 -0
  14. frontend/src/components/layout/TopBar.tsx +48 -0
  15. frontend/src/components/ui/badge.tsx +29 -0
  16. frontend/src/components/ui/button.tsx +46 -0
  17. frontend/src/components/ui/card.tsx +45 -0
  18. frontend/src/components/ui/input.tsx +20 -0
  19. frontend/src/components/ui/label.tsx +21 -0
  20. frontend/src/components/ui/separator.tsx +25 -0
  21. frontend/src/components/ui/slider.tsx +30 -0
  22. frontend/src/components/ui/sonner.tsx +20 -0
  23. frontend/src/components/ui/switch.tsx +31 -0
  24. frontend/src/components/ui/table.tsx +50 -0
  25. frontend/src/components/ui/tabs.tsx +52 -0
  26. frontend/src/components/ui/textarea.tsx +19 -0
  27. frontend/src/components/ui/tooltip.tsx +29 -0
  28. frontend/src/lib/dashboard.tsx +244 -0
  29. frontend/src/main.tsx +10 -0
  30. frontend/src/modules/activity/ActivityPanel.tsx +212 -0
  31. frontend/src/modules/detector/DetectorPanel.tsx +221 -0
  32. frontend/src/modules/preview/PreviewPanel.tsx +135 -0
  33. frontend/src/modules/rules/RuleStudioPanel.tsx +158 -0
  34. frontend/src/modules/settings/SettingsPanel.tsx +70 -0
  35. frontend/src/styles/index.css +173 -0
  36. frontend/tsconfig.app.json +22 -0
  37. frontend/tsconfig.app.tsbuildinfo +1 -0
  38. frontend/tsconfig.json +11 -0
  39. frontend/tsconfig.node.json +18 -0
  40. frontend/tsconfig.node.tsbuildinfo +1 -0
  41. poetry.lock +0 -0
  42. pyproject.toml +31 -0
  43. pytest.ini +3 -0
  44. requirements-dev.txt +2 -0
  45. requirements.txt +7 -0
  46. server.py +262 -0
  47. tests/test_automation.py +172 -0
  48. tests/test_detector.py +7 -0
  49. tests/test_llm.py +126 -0
  50. tests/test_store.py +75 -0
.gitignore CHANGED
@@ -16,3 +16,8 @@ models/
16
  *.pt
17
  *.onnx
18
  *.engine
 
 
 
 
 
 
16
  *.pt
17
  *.onnx
18
  *.engine
19
+ CLAUDE.md
20
+ *.ts
21
+ .claude
22
+
23
+ design_previews/
app.py ADDED
@@ -0,0 +1,1324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import html
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import gradio as gr
11
+
12
+ from tiny_trigger.actions import dispatch_events
13
+ from tiny_trigger.automation import evaluate_video_detections, load_automation_text
14
+ from tiny_trigger.detector import parse_class_prompt
15
+ from tiny_trigger.llm import compile_automation_with_llamacpp
16
+ from tiny_trigger.store import (
17
+ EVENTS_PATH,
18
+ RuntimeState,
19
+ append_events,
20
+ load_local_config,
21
+ load_runtime_state,
22
+ load_saved_automations,
23
+ save_automations,
24
+ save_runtime_state,
25
+ )
26
+ from tiny_trigger.video import process_video, render_automation_video
27
+
28
+
29
+ LOCAL_CONFIG = load_local_config()
30
+ DEFAULT_CLASSES = os.getenv(
31
+ "TINY_TRIGGER_CLASSES",
32
+ LOCAL_CONFIG.default_classes or "person, wheel, clothes, chair, monitor, steering wheel, bag, sticker",
33
+ )
34
+ DEFAULT_MODEL = os.getenv("TINY_TRIGGER_YOLOE_MODEL", LOCAL_CONFIG.default_detector_model or "yoloe-26s-seg.pt")
35
+ DEFAULT_DEVICE = os.getenv("TINY_TRIGGER_DEVICE", LOCAL_CONFIG.default_device or "auto")
36
+ DEFAULT_IMAGE_SIZE = int(os.getenv("TINY_TRIGGER_IMAGE_SIZE", LOCAL_CONFIG.default_image_size or 960))
37
+ DEFAULT_MAX_DETECTIONS = int(os.getenv("TINY_TRIGGER_MAX_DETECTIONS", LOCAL_CONFIG.default_max_detections or 100))
38
+ DEFAULT_WEBHOOK_URL = os.getenv("TINY_TRIGGER_WEBHOOK_URL", LOCAL_CONFIG.webhook_url or "")
39
+ DEFAULT_LLAMACPP_BASE_URL = os.getenv(
40
+ "TINY_TRIGGER_LLAMACPP_BASE_URL",
41
+ LOCAL_CONFIG.llamacpp_base_url or "http://127.0.0.1:8080/v1",
42
+ )
43
+ DEFAULT_LLAMACPP_MODEL = os.getenv(
44
+ "TINY_TRIGGER_LLAMACPP_MODEL",
45
+ LOCAL_CONFIG.llamacpp_model or "ggml-org/Qwen3-1.7B-GGUF:Q4_K_M",
46
+ )
47
+
48
+
49
+ def _ensure_ffmpeg_available() -> None:
50
+ if shutil.which("ffmpeg") and shutil.which("ffprobe"):
51
+ return
52
+
53
+ try:
54
+ import static_ffmpeg
55
+
56
+ static_ffmpeg.add_paths()
57
+ except ImportError:
58
+ pass
59
+ if shutil.which("ffmpeg") and shutil.which("ffprobe"):
60
+ return
61
+
62
+ try:
63
+ import imageio_ffmpeg
64
+ except ImportError:
65
+ return
66
+
67
+ ffmpeg_exe = Path(imageio_ffmpeg.get_ffmpeg_exe())
68
+ local_bin = Path(".local") / "bin"
69
+ local_bin.mkdir(parents=True, exist_ok=True)
70
+ ffmpeg_link = local_bin / ("ffmpeg.exe" if os.name == "nt" else "ffmpeg")
71
+ if not ffmpeg_link.exists():
72
+ try:
73
+ ffmpeg_link.symlink_to(ffmpeg_exe)
74
+ except OSError:
75
+ shutil.copy2(ffmpeg_exe, ffmpeg_link)
76
+ ffmpeg_link.chmod(0o755)
77
+ os.environ["PATH"] = f"{local_bin.resolve()}{os.pathsep}{os.environ.get('PATH', '')}"
78
+
79
+
80
+ _ensure_ffmpeg_available()
81
+
82
+ APP_CSS = """
83
+ :root {
84
+ --tt-bg: #f6f7f8;
85
+ --tt-surface: #ffffff;
86
+ --tt-surface-soft: #eef2f5;
87
+ --tt-text: #17202a;
88
+ --tt-muted: #687484;
89
+ --tt-border: #dce1e7;
90
+ --tt-border-strong: #cbd3dd;
91
+ --tt-red: #d93a2f;
92
+ --tt-red-dark: #b92f27;
93
+ --tt-teal: #109f8f;
94
+ --tt-blue: #3569d4;
95
+ --tt-lime: #d5ef4a;
96
+ --tt-shadow: 0 20px 45px rgba(31, 41, 55, 0.08);
97
+ --tt-radius: 8px;
98
+ }
99
+
100
+ body,
101
+ .gradio-container {
102
+ background:
103
+ linear-gradient(180deg, rgba(16, 159, 143, 0.08), rgba(246, 247, 248, 0) 310px),
104
+ var(--tt-bg) !important;
105
+ color: var(--tt-text) !important;
106
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
107
+ letter-spacing: 0 !important;
108
+ }
109
+
110
+ .gradio-container {
111
+ max-width: 1440px !important;
112
+ margin: 0 auto !important;
113
+ padding: 22px 24px 32px !important;
114
+ }
115
+
116
+ footer {
117
+ display: none !important;
118
+ }
119
+
120
+ .tt-hero {
121
+ display: flex;
122
+ align-items: flex-start;
123
+ justify-content: space-between;
124
+ gap: 18px;
125
+ margin-bottom: 18px;
126
+ }
127
+
128
+ .tt-kicker {
129
+ margin: 0 0 5px;
130
+ color: var(--tt-muted);
131
+ font-size: 12px;
132
+ font-weight: 800;
133
+ letter-spacing: 0.08em;
134
+ text-transform: uppercase;
135
+ }
136
+
137
+ .tt-title {
138
+ margin: 0;
139
+ color: var(--tt-text);
140
+ font-size: 31px;
141
+ line-height: 1.05;
142
+ font-weight: 820;
143
+ }
144
+
145
+ .tt-subtitle {
146
+ margin: 8px 0 0;
147
+ color: var(--tt-muted);
148
+ font-size: 14px;
149
+ }
150
+
151
+ .tt-chips {
152
+ display: flex;
153
+ flex-wrap: wrap;
154
+ justify-content: flex-end;
155
+ gap: 9px;
156
+ }
157
+
158
+ .tt-chip {
159
+ display: inline-flex;
160
+ align-items: center;
161
+ min-height: 31px;
162
+ padding: 0 12px;
163
+ border: 1px solid var(--tt-border);
164
+ border-radius: 999px;
165
+ background: rgba(255, 255, 255, 0.76);
166
+ color: var(--tt-text);
167
+ font-size: 12px;
168
+ font-weight: 800;
169
+ white-space: nowrap;
170
+ box-shadow: 0 8px 20px rgba(31, 41, 55, 0.04);
171
+ }
172
+
173
+ .tt-chip.live {
174
+ border-color: transparent;
175
+ background: var(--tt-teal);
176
+ color: #ffffff;
177
+ }
178
+
179
+ .tt-grid {
180
+ gap: 16px !important;
181
+ align-items: stretch !important;
182
+ }
183
+
184
+ .tt-panel {
185
+ border: 1px solid var(--tt-border) !important;
186
+ border-radius: var(--tt-radius) !important;
187
+ background: var(--tt-surface) !important;
188
+ color: var(--tt-text) !important;
189
+ box-shadow: var(--tt-shadow) !important;
190
+ overflow: hidden !important;
191
+ }
192
+
193
+ .tt-panel > div,
194
+ .tt-panel .form,
195
+ .tt-panel .block,
196
+ .tt-panel .wrap,
197
+ .tt-panel .contain,
198
+ .tt-video-panel > div,
199
+ .tt-data-panel > div {
200
+ background: var(--tt-surface) !important;
201
+ color: var(--tt-text) !important;
202
+ }
203
+
204
+ .tt-panel > .form,
205
+ .tt-panel .form {
206
+ border: 0 !important;
207
+ background: transparent !important;
208
+ }
209
+
210
+ .tt-panel-title h2,
211
+ .tt-panel-title h3,
212
+ .tt-section h2,
213
+ .tt-section h3 {
214
+ margin: 0 !important;
215
+ color: var(--tt-muted) !important;
216
+ font-size: 12px !important;
217
+ font-weight: 850 !important;
218
+ letter-spacing: 0.08em !important;
219
+ line-height: 1.2 !important;
220
+ text-transform: uppercase !important;
221
+ }
222
+
223
+ .tt-panel-title {
224
+ margin-bottom: 10px !important;
225
+ }
226
+
227
+ .tt-panel .block,
228
+ .tt-panel .block.padded {
229
+ border-color: var(--tt-border) !important;
230
+ border-radius: 7px !important;
231
+ }
232
+
233
+ .tt-panel label,
234
+ .tt-panel .label-wrap,
235
+ .tt-panel .wrap label,
236
+ .tt-panel .block-label {
237
+ color: var(--tt-muted) !important;
238
+ font-size: 12px !important;
239
+ font-weight: 780 !important;
240
+ }
241
+
242
+ .tt-panel textarea,
243
+ .tt-panel input,
244
+ .tt-panel select {
245
+ color: var(--tt-text) !important;
246
+ background: #ffffff !important;
247
+ }
248
+
249
+ .tt-panel textarea,
250
+ .tt-panel input {
251
+ border-color: var(--tt-border-strong) !important;
252
+ }
253
+
254
+ .tt-panel .secondary,
255
+ .tt-panel button.secondary {
256
+ border: 1px solid var(--tt-border) !important;
257
+ background: var(--tt-surface-soft) !important;
258
+ color: var(--tt-text) !important;
259
+ }
260
+
261
+ .tt-primary button,
262
+ .tt-panel button.primary,
263
+ button.primary {
264
+ border: 0 !important;
265
+ background: var(--tt-red) !important;
266
+ color: #ffffff !important;
267
+ font-weight: 820 !important;
268
+ }
269
+
270
+ .tt-panel button.primary:hover,
271
+ button.primary:hover {
272
+ background: var(--tt-red-dark) !important;
273
+ }
274
+
275
+ .tt-run-row {
276
+ gap: 10px !important;
277
+ align-items: stretch !important;
278
+ flex-wrap: nowrap !important;
279
+ }
280
+
281
+ .tt-run-row button,
282
+ .tt-panel button {
283
+ min-height: 40px !important;
284
+ border-radius: 7px !important;
285
+ padding: 0 14px !important;
286
+ font-size: 13px !important;
287
+ font-weight: 820 !important;
288
+ line-height: 1.1 !important;
289
+ white-space: nowrap !important;
290
+ }
291
+
292
+ .tt-run-row button {
293
+ width: 100% !important;
294
+ }
295
+
296
+ .tt-run-row .form,
297
+ .tt-run-row > div {
298
+ flex: 1 1 0 !important;
299
+ min-width: 0 !important;
300
+ }
301
+
302
+ .gradio-container button[role="tab"] {
303
+ min-height: 36px !important;
304
+ border: 1px solid var(--tt-border) !important;
305
+ border-radius: 7px !important;
306
+ background: rgba(255, 255, 255, 0.78) !important;
307
+ color: var(--tt-text) !important;
308
+ font-size: 13px !important;
309
+ font-weight: 820 !important;
310
+ box-shadow: none !important;
311
+ }
312
+
313
+ .gradio-container button[role="tab"][aria-selected="true"] {
314
+ border-color: transparent !important;
315
+ background: var(--tt-blue) !important;
316
+ color: #ffffff !important;
317
+ }
318
+
319
+ .tt-video-panel {
320
+ border: 1px solid var(--tt-border) !important;
321
+ border-radius: var(--tt-radius) !important;
322
+ background: var(--tt-surface) !important;
323
+ box-shadow: var(--tt-shadow) !important;
324
+ overflow: hidden !important;
325
+ }
326
+
327
+ .tt-video-panel .tabs,
328
+ .tt-data-panel .tabs,
329
+ .tt-automation-panel .tabs {
330
+ border: 0 !important;
331
+ }
332
+
333
+ .tt-video-panel .tab-nav,
334
+ .tt-data-panel .tab-nav,
335
+ .tt-automation-panel .tab-nav {
336
+ gap: 7px !important;
337
+ padding: 0 !important;
338
+ border: 0 !important;
339
+ }
340
+
341
+ .tt-video-panel button[role="tab"],
342
+ .tt-data-panel button[role="tab"],
343
+ .tt-automation-panel button[role="tab"] {
344
+ min-height: 34px !important;
345
+ border: 1px solid var(--tt-border) !important;
346
+ border-radius: 7px !important;
347
+ background: var(--tt-surface-soft) !important;
348
+ color: var(--tt-text) !important;
349
+ font-size: 12px !important;
350
+ font-weight: 820 !important;
351
+ }
352
+
353
+ .tt-video-panel button[role="tab"][aria-selected="true"],
354
+ .tt-data-panel button[role="tab"][aria-selected="true"],
355
+ .tt-automation-panel button[role="tab"][aria-selected="true"] {
356
+ background: var(--tt-blue) !important;
357
+ color: #ffffff !important;
358
+ }
359
+
360
+ .tt-result-video {
361
+ min-height: 430px !important;
362
+ }
363
+
364
+ .tt-result-video video {
365
+ border-radius: 8px !important;
366
+ background:
367
+ linear-gradient(90deg, rgba(23, 32, 42, 0.07) 1px, transparent 1px),
368
+ linear-gradient(0deg, rgba(23, 32, 42, 0.07) 1px, transparent 1px),
369
+ #edf1f5 !important;
370
+ background-size: 48px 48px, 48px 48px, 100% 100% !important;
371
+ }
372
+
373
+ .tt-status-grid {
374
+ gap: 10px !important;
375
+ }
376
+
377
+ .tt-status-card {
378
+ min-height: 82px;
379
+ padding: 13px 14px;
380
+ border: 1px solid var(--tt-border);
381
+ border-radius: var(--tt-radius);
382
+ background: #ffffff;
383
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.4);
384
+ }
385
+
386
+ .tt-status-card strong {
387
+ display: block;
388
+ margin-bottom: 4px;
389
+ color: var(--tt-text);
390
+ font-size: 14px;
391
+ line-height: 1.2;
392
+ }
393
+
394
+ .tt-status-card span {
395
+ display: block;
396
+ color: var(--tt-muted);
397
+ font-size: 12px;
398
+ line-height: 1.35;
399
+ }
400
+
401
+ .tt-status-card.success {
402
+ border-color: rgba(16, 159, 143, 0.35);
403
+ background: rgba(16, 159, 143, 0.08);
404
+ }
405
+
406
+ .tt-status-card.action {
407
+ border-color: rgba(217, 58, 47, 0.32);
408
+ background: rgba(217, 58, 47, 0.09);
409
+ }
410
+
411
+ .tt-status-card.warning {
412
+ border-color: rgba(213, 160, 20, 0.38);
413
+ background: rgba(213, 160, 20, 0.11);
414
+ }
415
+
416
+ .tt-status-card.idle {
417
+ background: #f8fafc;
418
+ }
419
+
420
+ .tt-label-audit {
421
+ display: grid;
422
+ gap: 10px;
423
+ padding: 13px 14px;
424
+ border: 1px solid rgba(16, 159, 143, 0.28);
425
+ border-radius: var(--tt-radius);
426
+ background: rgba(16, 159, 143, 0.07);
427
+ }
428
+
429
+ .tt-label-audit strong {
430
+ display: block;
431
+ color: var(--tt-text);
432
+ font-size: 14px;
433
+ line-height: 1.2;
434
+ }
435
+
436
+ .tt-label-audit span {
437
+ color: var(--tt-muted);
438
+ font-size: 12px;
439
+ }
440
+
441
+ .tt-chip-row {
442
+ display: flex;
443
+ flex-wrap: wrap;
444
+ gap: 8px;
445
+ }
446
+
447
+ .tt-flow-copy {
448
+ margin: 0 0 12px;
449
+ color: var(--tt-muted);
450
+ font-size: 13px;
451
+ line-height: 1.45;
452
+ }
453
+
454
+ .tt-code textarea,
455
+ .tt-code code,
456
+ .tt-code pre {
457
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace !important;
458
+ font-size: 12px !important;
459
+ line-height: 1.45 !important;
460
+ }
461
+
462
+ .tt-data-panel {
463
+ border: 1px solid var(--tt-border) !important;
464
+ border-radius: var(--tt-radius) !important;
465
+ background: var(--tt-surface) !important;
466
+ box-shadow: var(--tt-shadow) !important;
467
+ overflow: hidden !important;
468
+ }
469
+
470
+ .tt-data-panel table {
471
+ font-size: 13px !important;
472
+ }
473
+
474
+ .tt-data-panel th {
475
+ color: var(--tt-muted) !important;
476
+ font-size: 11px !important;
477
+ font-weight: 820 !important;
478
+ text-transform: uppercase !important;
479
+ }
480
+
481
+ .tt-compact textarea {
482
+ min-height: 84px !important;
483
+ }
484
+
485
+ .tt-demo-state {
486
+ margin-top: 10px;
487
+ }
488
+
489
+ @media (max-width: 980px) {
490
+ .gradio-container {
491
+ padding: 16px !important;
492
+ }
493
+
494
+ .tt-hero {
495
+ flex-direction: column;
496
+ }
497
+
498
+ .tt-chips {
499
+ justify-content: flex-start;
500
+ }
501
+
502
+ .tt-result-video {
503
+ min-height: 300px !important;
504
+ }
505
+ }
506
+ """
507
+
508
+ FALLBACK_RULES = """rules:
509
+ - name: person-near-steering-wheel
510
+ when:
511
+ all:
512
+ - present: {label: person, min_count: 1}
513
+ - near: {a: person, b: steering wheel, max_distance: 0.45}
514
+ gate:
515
+ enabled: true
516
+ cooldown: {key: turn-on-pc, minutes: 5}
517
+ then:
518
+ - type: webhook
519
+ name: turn on pc
520
+ """
521
+ SAVED_AUTOMATIONS = load_saved_automations()
522
+ DEFAULT_RULES = (
523
+ SAVED_AUTOMATIONS.model_dump_json(by_alias=True, indent=2)
524
+ if SAVED_AUTOMATIONS
525
+ else FALLBACK_RULES
526
+ )
527
+
528
+
529
+ def _detection_rows(detections: list[Any]) -> list[list[Any]]:
530
+ return [
531
+ [
532
+ det.frame_index,
533
+ round(det.timestamp_sec, 2),
534
+ det.label,
535
+ round(det.confidence, 3),
536
+ [round(value, 1) for value in det.bbox_xyxy],
537
+ [round(value, 3) for value in det.bbox_xyxy_norm],
538
+ ]
539
+ for det in detections
540
+ ]
541
+
542
+
543
+ def _event_rows(events: list[Any]) -> list[list[Any]]:
544
+ return [
545
+ [
546
+ event.rule,
547
+ event.action,
548
+ event.type,
549
+ event.status,
550
+ event.frame_index,
551
+ round(event.timestamp_sec, 2),
552
+ event.response_status or "",
553
+ event.error or "",
554
+ ]
555
+ for event in events
556
+ ]
557
+
558
+
559
+ def _result_json(result: Any) -> dict[str, Any]:
560
+ return json.loads(result.model_dump_json())
561
+
562
+
563
+ def _status_card(tone: str, title: str, body: str) -> str:
564
+ return f"""
565
+ <div class="tt-status-card {tone}">
566
+ <strong>{title}</strong>
567
+ <span>{body}</span>
568
+ </div>
569
+ """
570
+
571
+
572
+ def _rules_json(document: Any) -> dict[str, Any]:
573
+ return json.loads(document.model_dump_json(by_alias=True))
574
+
575
+
576
+ def _automation_labels_from_document(document: Any) -> list[str]:
577
+ labels: list[str] = []
578
+ for rule in document.rules:
579
+ for condition in [*rule.when.all_conditions, *rule.when.any_conditions]:
580
+ if condition.present:
581
+ labels.append(condition.present.label)
582
+ if condition.count:
583
+ labels.append(condition.count.label)
584
+ if condition.near:
585
+ labels.extend([condition.near.a, condition.near.b])
586
+ return parse_class_prompt(labels)
587
+
588
+
589
+ def _automation_labels_from_text(rules_text: str) -> list[str]:
590
+ return _automation_labels_from_document(load_automation_text(rules_text))
591
+
592
+
593
+ def _automation_labels_from_rule(rule: Any) -> list[str]:
594
+ labels: list[str] = []
595
+ for condition in [*rule.when.all_conditions, *rule.when.any_conditions]:
596
+ if condition.present:
597
+ labels.append(condition.present.label)
598
+ if condition.count:
599
+ labels.append(condition.count.label)
600
+ if condition.near:
601
+ labels.extend([condition.near.a, condition.near.b])
602
+ return parse_class_prompt(labels)
603
+
604
+
605
+ def _labels_summary(labels: list[str]) -> str:
606
+ if not labels:
607
+ return "No detector labels found in the active automations."
608
+ return ", ".join(labels)
609
+
610
+
611
+ def _labels_card(labels: list[str]) -> str:
612
+ chips = "".join(f"<span class=\"tt-chip\">{html.escape(label)}</span>" for label in labels)
613
+ chips = chips or "<span class=\"tt-chip\">No labels</span>"
614
+ return f"""
615
+ <div class="tt-label-audit">
616
+ <div>
617
+ <strong>Detector audit</strong>
618
+ <span>{len(labels)} label{'s' if len(labels) != 1 else ''} derived from active automations</span>
619
+ </div>
620
+ <div class="tt-chip-row">{chips}</div>
621
+ </div>
622
+ """
623
+
624
+
625
+ def _condition_summary(rule: Any) -> str:
626
+ parts: list[str] = []
627
+ for condition in [*rule.when.all_conditions, *rule.when.any_conditions]:
628
+ if condition.present:
629
+ parts.append(f"present {condition.present.label}")
630
+ if condition.count:
631
+ parts.append(f"{condition.count.label} >= {condition.count.minimum}")
632
+ if condition.near:
633
+ parts.append(f"{condition.near.a} near {condition.near.b}")
634
+ if condition.cooldown:
635
+ duration = condition.cooldown.minutes or condition.cooldown.seconds or 0
636
+ unit = "min" if condition.cooldown.minutes is not None else "sec"
637
+ parts.append(f"cooldown {duration:g}{unit}")
638
+ return " + ".join(parts)
639
+
640
+
641
+ def _action_summary(rule: Any) -> str:
642
+ return ", ".join(f"{action.type}: {action.name}" for action in rule.then)
643
+
644
+
645
+ def _rule_rows(document: Any) -> list[list[Any]]:
646
+ rows: list[list[Any]] = []
647
+ for rule in document.rules:
648
+ rows.append(
649
+ [
650
+ "on" if rule.gate.enabled else "off",
651
+ rule.name,
652
+ _labels_summary(_automation_labels_from_rule(rule)),
653
+ _condition_summary(rule),
654
+ _action_summary(rule),
655
+ ]
656
+ )
657
+ return rows
658
+
659
+
660
+ def _rule_names(document: Any) -> list[str]:
661
+ return [rule.name for rule in document.rules]
662
+
663
+
664
+ def _rule_enabled(document: Any, rule_name: str | None) -> bool:
665
+ for rule in document.rules:
666
+ if rule.name == rule_name:
667
+ return rule.gate.enabled
668
+ return False
669
+
670
+
671
+ def _rules_text_from_data(data: dict[str, Any]) -> str:
672
+ document = load_automation_text(json.dumps(data))
673
+ return document.model_dump_json(by_alias=True, indent=2)
674
+
675
+
676
+ def _merge_generated_rules(existing_rules_text: str, generated_document: Any) -> Any:
677
+ try:
678
+ existing_data = _rules_json(load_automation_text(existing_rules_text))
679
+ existing_rules = existing_data["rules"]
680
+ except Exception:
681
+ existing_rules = []
682
+
683
+ generated_rules = _rules_json(generated_document)["rules"]
684
+ merged_by_name: dict[str, dict[str, Any]] = {rule["name"]: rule for rule in existing_rules}
685
+ ordered_names = [rule["name"] for rule in existing_rules]
686
+ for rule in generated_rules:
687
+ if rule["name"] not in merged_by_name:
688
+ ordered_names.append(rule["name"])
689
+ merged_by_name[rule["name"]] = rule
690
+
691
+ merged_rules = [merged_by_name[name] for name in ordered_names]
692
+ return load_automation_text(json.dumps({"rules": merged_rules}))
693
+
694
+
695
+ def refresh_automation_manager(rules_text: str) -> tuple[list[list[Any]], Any, bool, dict[str, Any], str, str, str]:
696
+ try:
697
+ document = load_automation_text(rules_text)
698
+ except Exception as exc:
699
+ raise gr.Error(f"Rules did not validate: {exc}") from exc
700
+ names = _rule_names(document)
701
+ selected = names[0] if names else None
702
+ labels = _automation_labels_from_document(document)
703
+ return (
704
+ _rule_rows(document),
705
+ gr.update(choices=names, value=selected),
706
+ _rule_enabled(document, selected),
707
+ {"status": "valid", "rules": _rules_json(document)["rules"], "labels": labels},
708
+ _status_card("success", "Manager refreshed", f"{len(names)} automation(s) loaded."),
709
+ _labels_card(labels),
710
+ _labels_card(labels),
711
+ )
712
+
713
+
714
+ def select_managed_rule(rules_text: str, rule_name: str | None) -> tuple[bool, str]:
715
+ document = load_automation_text(rules_text)
716
+ return (
717
+ _rule_enabled(document, rule_name),
718
+ _status_card("idle", "Automation selected", rule_name or "No automation selected."),
719
+ )
720
+
721
+
722
+ def set_rule_enabled(rules_text: str, rule_name: str | None, enabled: bool) -> tuple[
723
+ str,
724
+ list[list[Any]],
725
+ Any,
726
+ bool,
727
+ dict[str, Any],
728
+ str,
729
+ str,
730
+ str,
731
+ ]:
732
+ if not rule_name:
733
+ raise gr.Error("Choose an automation first.")
734
+
735
+ document = load_automation_text(rules_text)
736
+ data = _rules_json(document)
737
+ changed = False
738
+ for rule in data["rules"]:
739
+ if rule["name"] == rule_name:
740
+ rule.setdefault("gate", {})
741
+ rule["gate"]["enabled"] = enabled
742
+ changed = True
743
+ break
744
+ if not changed:
745
+ raise gr.Error(f"Automation not found: {rule_name}")
746
+
747
+ updated_text = _rules_text_from_data(data)
748
+ updated_document = load_automation_text(updated_text)
749
+ labels = _automation_labels_from_document(updated_document)
750
+ names = _rule_names(updated_document)
751
+ return (
752
+ updated_text,
753
+ _rule_rows(updated_document),
754
+ gr.update(choices=names, value=rule_name),
755
+ enabled,
756
+ {"status": "updated", "rules": _rules_json(updated_document)["rules"], "labels": labels},
757
+ _status_card("success", "Automation updated", f"{rule_name} is {'on' if enabled else 'off'}."),
758
+ _labels_card(labels),
759
+ _labels_card(labels),
760
+ )
761
+
762
+
763
+ def _video_file_path(value: Any) -> str | None:
764
+ if value is None:
765
+ return None
766
+ if isinstance(value, str):
767
+ return value
768
+ if isinstance(value, Path):
769
+ return str(value)
770
+ if isinstance(value, dict):
771
+ return value.get("path") or value.get("name")
772
+ path = getattr(value, "path", None) or getattr(value, "name", None)
773
+ return str(path) if path else None
774
+
775
+
776
+ def reset_demo_state() -> tuple[str, dict[str, Any]]:
777
+ save_runtime_state(RuntimeState())
778
+ if EVENTS_PATH.exists():
779
+ EVENTS_PATH.unlink()
780
+ return (
781
+ _status_card("success", "Demo state reset", "Cooldown memory and action history are clear."),
782
+ {"status": "reset", "last_fired": {}, "events": "cleared"},
783
+ )
784
+
785
+
786
+ def run_detector(
787
+ video_path: str | None,
788
+ class_prompt: str,
789
+ confidence: float,
790
+ frame_stride: int,
791
+ max_frames: int,
792
+ model_name: str,
793
+ image_size: int,
794
+ device: str,
795
+ max_detections: int,
796
+ ) -> tuple[str | None, list[list[Any]], dict[str, Any], str]:
797
+ if not video_path:
798
+ raise gr.Error("Upload a video first.")
799
+ if not parse_class_prompt(class_prompt):
800
+ raise gr.Error("Enter at least one class prompt.")
801
+
802
+ result = process_video(
803
+ video_path=video_path,
804
+ class_prompt=class_prompt,
805
+ confidence=confidence,
806
+ frame_stride=frame_stride,
807
+ max_frames=max_frames,
808
+ model_name=model_name or DEFAULT_MODEL,
809
+ image_size=image_size,
810
+ device=None if device == "auto" else device,
811
+ max_detections=max_detections,
812
+ )
813
+ return (
814
+ result.output_video_path,
815
+ _detection_rows(result.detections),
816
+ _result_json(result),
817
+ _status_card(
818
+ "success",
819
+ f"{len(result.detections)} detections",
820
+ f"Processed {result.processed_frames} frames at {result.output_fps:.1f} FPS output.",
821
+ ),
822
+ )
823
+
824
+
825
+ def compile_rules(
826
+ instruction: str,
827
+ current_rules_text: str,
828
+ label_hints: str,
829
+ base_url: str,
830
+ model: str,
831
+ ) -> tuple[str, dict[str, Any], str, str, list[list[Any]], Any, bool, str]:
832
+ if not instruction.strip():
833
+ raise gr.Error("Describe the automation first.")
834
+
835
+ try:
836
+ compiled = compile_automation_with_llamacpp(
837
+ instruction=instruction,
838
+ class_names=parse_class_prompt(label_hints),
839
+ base_url=base_url or DEFAULT_LLAMACPP_BASE_URL,
840
+ model=model or DEFAULT_LLAMACPP_MODEL,
841
+ )
842
+ except Exception as exc:
843
+ raise gr.Error(f"Could not compile automation: {exc}") from exc
844
+ merged_document = _merge_generated_rules(current_rules_text, compiled.document)
845
+ rules_json = merged_document.model_dump_json(by_alias=True, indent=2)
846
+ labels = _automation_labels_from_document(merged_document)
847
+ names = _rule_names(merged_document)
848
+ generated_names = _rule_names(compiled.document)
849
+ selected = generated_names[0] if generated_names else (names[0] if names else None)
850
+ labels_card = _labels_card(labels)
851
+ return (
852
+ rules_json,
853
+ {
854
+ "raw_text": compiled.raw_text,
855
+ "generated": _rules_json(compiled.document)["rules"],
856
+ "active_rules": json.loads(rules_json)["rules"],
857
+ "labels": labels,
858
+ },
859
+ _status_card(
860
+ "success",
861
+ "Generated and added",
862
+ f"{len(compiled.document.rules)} new rule(s). {len(merged_document.rules)} active automation(s).",
863
+ ),
864
+ labels_card,
865
+ _rule_rows(merged_document),
866
+ gr.update(choices=names, value=selected),
867
+ _rule_enabled(merged_document, selected),
868
+ labels_card,
869
+ )
870
+
871
+
872
+ def validate_rules(rules_text: str) -> tuple[dict[str, Any], str, str, list[list[Any]], Any, bool, str]:
873
+ try:
874
+ document = load_automation_text(rules_text)
875
+ except Exception as exc:
876
+ raise gr.Error(f"Rules did not validate: {exc}") from exc
877
+ labels = _automation_labels_from_document(document)
878
+ names = _rule_names(document)
879
+ selected = names[0] if names else None
880
+ labels_card = _labels_card(labels)
881
+ return (
882
+ {"status": "valid", "rules": _rules_json(document)["rules"], "labels": labels},
883
+ _status_card("success", "Validation passed", f"{len(document.rules)} rule(s) checked."),
884
+ labels_card,
885
+ _rule_rows(document),
886
+ gr.update(choices=names, value=selected),
887
+ _rule_enabled(document, selected),
888
+ labels_card,
889
+ )
890
+
891
+
892
+ def save_rules(rules_text: str) -> tuple[str, dict[str, Any], str, str, list[list[Any]], Any, bool, str]:
893
+ document = load_automation_text(rules_text)
894
+ save_automations(document)
895
+ rules_json = document.model_dump_json(by_alias=True, indent=2)
896
+ labels = _automation_labels_from_document(document)
897
+ names = _rule_names(document)
898
+ selected = names[0] if names else None
899
+ labels_card = _labels_card(labels)
900
+ return (
901
+ rules_json,
902
+ {"status": "saved", "rules": json.loads(rules_json), "labels": labels},
903
+ _status_card(
904
+ "success",
905
+ "Saved locally",
906
+ f"{len(document.rules)} rule(s) stored. Monitor will watch: {_labels_summary(labels)}.",
907
+ ),
908
+ labels_card,
909
+ _rule_rows(document),
910
+ gr.update(choices=names, value=selected),
911
+ _rule_enabled(document, selected),
912
+ labels_card,
913
+ )
914
+
915
+
916
+ def run_monitor_clip(
917
+ clip_file: Any,
918
+ rules_text: str,
919
+ confidence: float,
920
+ frame_stride: int,
921
+ max_frames: int,
922
+ model_name: str,
923
+ image_size: int,
924
+ device: str,
925
+ max_detections: int,
926
+ enable_webhooks: bool,
927
+ webhook_url: str,
928
+ ) -> tuple[str | None, list[list[Any]], list[list[Any]], dict[str, Any], str, str]:
929
+ video_path = _video_file_path(clip_file)
930
+ if not video_path:
931
+ raise gr.Error("Upload a clip first.")
932
+
933
+ rules = load_automation_text(rules_text)
934
+ labels = _automation_labels_from_document(rules)
935
+ if not labels:
936
+ raise gr.Error("The active automations do not mention any detector labels.")
937
+
938
+ state = load_runtime_state()
939
+ result = process_video(
940
+ video_path=video_path,
941
+ class_prompt=labels,
942
+ confidence=confidence,
943
+ frame_stride=frame_stride,
944
+ max_frames=max_frames,
945
+ model_name=model_name or DEFAULT_MODEL,
946
+ image_size=image_size,
947
+ device=None if device == "auto" else device,
948
+ max_detections=max_detections,
949
+ )
950
+ events, last_fired = evaluate_video_detections(rules.rules, result.detections, last_fired=state.last_fired)
951
+ dispatched = dispatch_events(events, enable_webhooks=enable_webhooks, webhook_url=webhook_url or None)
952
+ state.last_fired = last_fired
953
+ save_runtime_state(state)
954
+ append_events(dispatched)
955
+ automation_video_path = render_automation_video(
956
+ source_video_path=video_path,
957
+ detections=result.detections,
958
+ events=dispatched,
959
+ frame_stride=frame_stride,
960
+ max_frames=max_frames,
961
+ )
962
+
963
+ action_count = len(dispatched)
964
+ status_tone = "action" if action_count else "warning"
965
+ status_title = f"{action_count} event{'s' if action_count != 1 else ''}"
966
+ status_body = (
967
+ "Replay rendered with fired automation overlays."
968
+ if action_count
969
+ else f"No automations fired. Watched: {_labels_summary(labels)}."
970
+ )
971
+ return (
972
+ automation_video_path,
973
+ _event_rows(dispatched),
974
+ _detection_rows(result.detections),
975
+ {
976
+ "watched_labels": labels,
977
+ "video": _result_json(result),
978
+ "actions": [event.model_dump(mode="json") for event in dispatched],
979
+ },
980
+ _status_card(status_tone, status_title, status_body),
981
+ _labels_card(labels),
982
+ )
983
+
984
+
985
+ def run_detector_and_automations(
986
+ video_path: str | None,
987
+ class_prompt: str,
988
+ confidence: float,
989
+ frame_stride: int,
990
+ max_frames: int,
991
+ model_name: str,
992
+ image_size: int,
993
+ device: str,
994
+ max_detections: int,
995
+ rules_text: str,
996
+ enable_webhooks: bool,
997
+ webhook_url: str,
998
+ ) -> tuple[str | None, list[list[Any]], list[list[Any]], dict[str, Any], str]:
999
+ if not video_path:
1000
+ raise gr.Error("Upload a video first.")
1001
+
1002
+ rules = load_automation_text(rules_text)
1003
+ state = load_runtime_state()
1004
+ result = process_video(
1005
+ video_path=video_path,
1006
+ class_prompt=class_prompt,
1007
+ confidence=confidence,
1008
+ frame_stride=frame_stride,
1009
+ max_frames=max_frames,
1010
+ model_name=model_name or DEFAULT_MODEL,
1011
+ image_size=image_size,
1012
+ device=None if device == "auto" else device,
1013
+ max_detections=max_detections,
1014
+ )
1015
+ events, last_fired = evaluate_video_detections(rules.rules, result.detections, last_fired=state.last_fired)
1016
+ dispatched = dispatch_events(events, enable_webhooks=enable_webhooks, webhook_url=webhook_url or None)
1017
+ state.last_fired = last_fired
1018
+ save_runtime_state(state)
1019
+ append_events(dispatched)
1020
+ automation_video_path = render_automation_video(
1021
+ source_video_path=video_path,
1022
+ detections=result.detections,
1023
+ events=dispatched,
1024
+ frame_stride=frame_stride,
1025
+ max_frames=max_frames,
1026
+ )
1027
+ action_count = len(dispatched)
1028
+ status_tone = "action" if action_count else "warning"
1029
+ status_title = f"{action_count} action{'s' if action_count != 1 else ''} fired"
1030
+ status_body = (
1031
+ "Automation replay rendered with fired-action overlay."
1032
+ if action_count
1033
+ else "Rules validated, but no frame matched the automation."
1034
+ )
1035
+ return (
1036
+ automation_video_path,
1037
+ _detection_rows(result.detections),
1038
+ _event_rows(dispatched),
1039
+ {
1040
+ "video": _result_json(result),
1041
+ "actions": [event.model_dump(mode="json") for event in dispatched],
1042
+ },
1043
+ _status_card(status_tone, status_title, status_body),
1044
+ )
1045
+
1046
+
1047
+ try:
1048
+ INITIAL_RULE_DOCUMENT = load_automation_text(DEFAULT_RULES)
1049
+ INITIAL_RULE_LABELS = _automation_labels_from_document(INITIAL_RULE_DOCUMENT)
1050
+ INITIAL_RULE_ROWS = _rule_rows(INITIAL_RULE_DOCUMENT)
1051
+ INITIAL_RULE_NAMES = _rule_names(INITIAL_RULE_DOCUMENT)
1052
+ INITIAL_SELECTED_RULE = INITIAL_RULE_NAMES[0] if INITIAL_RULE_NAMES else None
1053
+ INITIAL_SELECTED_ENABLED = _rule_enabled(INITIAL_RULE_DOCUMENT, INITIAL_SELECTED_RULE)
1054
+ except Exception:
1055
+ INITIAL_RULE_DOCUMENT = None
1056
+ INITIAL_RULE_LABELS = []
1057
+ INITIAL_RULE_ROWS = []
1058
+ INITIAL_RULE_NAMES = []
1059
+ INITIAL_SELECTED_RULE = None
1060
+ INITIAL_SELECTED_ENABLED = False
1061
+
1062
+
1063
+ with gr.Blocks(title="Tiny Trigger") as demo:
1064
+ gr.HTML(
1065
+ """
1066
+ <header class="tt-hero">
1067
+ <div>
1068
+ <p class="tt-kicker">Hackathon MVP</p>
1069
+ <h1 class="tt-title">Tiny Trigger</h1>
1070
+ <p class="tt-subtitle">Video events from validated automations</p>
1071
+ </div>
1072
+ <div class="tt-chips">
1073
+ <span class="tt-chip live">Validated rules</span>
1074
+ <span class="tt-chip">Open vocabulary</span>
1075
+ <span class="tt-chip">Camera-ready skeleton</span>
1076
+ </div>
1077
+ </header>
1078
+ """
1079
+ )
1080
+
1081
+ with gr.Tabs():
1082
+ with gr.Tab("Monitor"):
1083
+ with gr.Row(elem_classes=["tt-grid"]):
1084
+ with gr.Column(scale=4, min_width=320):
1085
+ with gr.Group(elem_classes=["tt-panel"]):
1086
+ gr.Markdown("### Clip Intake", elem_classes=["tt-panel-title"])
1087
+ gr.Markdown(
1088
+ "Upload a short clip. Tiny Trigger scans for the labels required by the active automations and renders the event replay.",
1089
+ elem_classes=["tt-flow-copy"],
1090
+ )
1091
+ monitor_clip_input = gr.File(
1092
+ label="Video clip",
1093
+ file_types=[".mp4", ".mov", ".m4v", ".webm"],
1094
+ type="filepath",
1095
+ )
1096
+ monitor_run_button = gr.Button("Run clip", variant="primary")
1097
+ with gr.Group(elem_classes=["tt-panel"]):
1098
+ gr.Markdown("### Active Watch", elem_classes=["tt-panel-title"])
1099
+ monitor_labels_output = gr.HTML(_labels_card(INITIAL_RULE_LABELS))
1100
+ monitor_status = gr.HTML(
1101
+ _status_card("idle", "Waiting for clip", "Upload a clip to run the active automations.")
1102
+ )
1103
+
1104
+ with gr.Column(scale=7, min_width=520):
1105
+ with gr.Group(elem_classes=["tt-video-panel"]):
1106
+ gr.Markdown("### Event Replay", elem_classes=["tt-panel-title"])
1107
+ automation_video = gr.Video(
1108
+ label="Automation replay",
1109
+ format="mp4",
1110
+ autoplay=True,
1111
+ elem_classes=["tt-result-video"],
1112
+ )
1113
+ with gr.Group(elem_classes=["tt-data-panel"]):
1114
+ gr.Markdown("### Events", elem_classes=["tt-panel-title"])
1115
+ actions_table = gr.Dataframe(
1116
+ headers=["rule", "action", "type", "status", "frame", "time", "response", "error"],
1117
+ datatype=["str", "str", "str", "str", "number", "number", "str", "str"],
1118
+ label="Action timeline",
1119
+ )
1120
+ with gr.Accordion("Run details", open=False):
1121
+ automation_detections = gr.Dataframe(
1122
+ headers=["frame", "time", "label", "confidence", "bbox", "bbox_norm"],
1123
+ datatype=["number", "number", "str", "number", "str", "str"],
1124
+ label="Detections",
1125
+ )
1126
+ automation_json = gr.JSON(label="Run JSON")
1127
+
1128
+ with gr.Tab("Automations"):
1129
+ with gr.Row(elem_classes=["tt-grid"]):
1130
+ with gr.Column(scale=4, min_width=340):
1131
+ with gr.Group(elem_classes=["tt-panel", "tt-automation-panel"]):
1132
+ gr.Markdown("### Generate Automation", elem_classes=["tt-panel-title"])
1133
+ instruction = gr.Textbox(
1134
+ label="Intent",
1135
+ value="If person near steering wheel then you have to turn on pc.",
1136
+ lines=5,
1137
+ elem_classes=["tt-compact"],
1138
+ )
1139
+ label_hints = gr.Textbox(
1140
+ label="Label hints for the generator",
1141
+ value=DEFAULT_CLASSES,
1142
+ lines=3,
1143
+ elem_classes=["tt-compact"],
1144
+ )
1145
+ with gr.Accordion("Compiler endpoint", open=False):
1146
+ llama_base_url = gr.Textbox(label="llama.cpp base URL", value=DEFAULT_LLAMACPP_BASE_URL)
1147
+ llama_model = gr.Textbox(label="llama.cpp model", value=DEFAULT_LLAMACPP_MODEL)
1148
+ with gr.Row(elem_classes=["tt-run-row"]):
1149
+ compile_button = gr.Button("Generate", variant="primary")
1150
+ validate_rules_button = gr.Button("Test", variant="secondary")
1151
+ save_rules_button = gr.Button("Save", variant="secondary")
1152
+
1153
+ with gr.Group(elem_classes=["tt-panel"]):
1154
+ gr.Markdown("### Manage Automations", elem_classes=["tt-panel-title"])
1155
+ automation_table = gr.Dataframe(
1156
+ headers=["state", "automation", "labels", "when", "then"],
1157
+ datatype=["str", "str", "str", "str", "str"],
1158
+ value=INITIAL_RULE_ROWS,
1159
+ interactive=False,
1160
+ label="Active automations",
1161
+ )
1162
+ managed_rule = gr.Dropdown(
1163
+ label="Automation",
1164
+ choices=INITIAL_RULE_NAMES,
1165
+ value=INITIAL_SELECTED_RULE,
1166
+ )
1167
+ managed_enabled = gr.Checkbox(label="Enabled", value=INITIAL_SELECTED_ENABLED)
1168
+ with gr.Row(elem_classes=["tt-run-row"]):
1169
+ apply_rule_button = gr.Button("Apply", variant="primary")
1170
+ refresh_manager_button = gr.Button("Refresh", variant="secondary")
1171
+ manager_save_button = gr.Button("Save all", variant="secondary")
1172
+
1173
+ with gr.Group(elem_classes=["tt-panel"]):
1174
+ gr.Markdown("### Audit", elem_classes=["tt-panel-title"])
1175
+ rules_status = gr.HTML(
1176
+ _status_card("idle", "Rules loaded", "Generate or test rules before running a clip.")
1177
+ )
1178
+ rules_labels_output = gr.HTML(_labels_card(INITIAL_RULE_LABELS))
1179
+
1180
+ with gr.Column(scale=7, min_width=520):
1181
+ with gr.Group(elem_classes=["tt-panel"]):
1182
+ gr.Markdown("### Active Rules", elem_classes=["tt-panel-title"])
1183
+ rules_text = gr.Code(
1184
+ label="Rules JSON/YAML",
1185
+ language="yaml",
1186
+ value=DEFAULT_RULES,
1187
+ lines=22,
1188
+ elem_classes=["tt-code"],
1189
+ )
1190
+ with gr.Group(elem_classes=["tt-data-panel"]):
1191
+ gr.Markdown("### Compile Test", elem_classes=["tt-panel-title"])
1192
+ compiled_json = gr.JSON(label="Rule audit")
1193
+
1194
+ with gr.Tab("Settings"):
1195
+ with gr.Row(elem_classes=["tt-grid"]):
1196
+ with gr.Column(scale=4, min_width=320):
1197
+ with gr.Group(elem_classes=["tt-panel"]):
1198
+ gr.Markdown("### Detector", elem_classes=["tt-panel-title"])
1199
+ model_name = gr.Textbox(label="YOLOE model", value=DEFAULT_MODEL)
1200
+ device = gr.Dropdown(label="Device", choices=["auto", "mps", "cuda:0", "cpu"], value=DEFAULT_DEVICE)
1201
+ image_size = gr.Slider(label="Image size", minimum=320, maximum=1536, step=32, value=DEFAULT_IMAGE_SIZE)
1202
+ confidence = gr.Slider(label="Sensitivity", minimum=0.05, maximum=0.95, step=0.05, value=0.25)
1203
+ frame_stride = gr.Slider(label="Frame stride", minimum=1, maximum=30, step=1, value=5)
1204
+ max_frames = gr.Slider(label="Max processed frames", minimum=10, maximum=600, step=10, value=120)
1205
+ max_detections = gr.Slider(
1206
+ label="Max detections",
1207
+ minimum=1,
1208
+ maximum=300,
1209
+ step=1,
1210
+ value=DEFAULT_MAX_DETECTIONS,
1211
+ )
1212
+ with gr.Column(scale=4, min_width=320):
1213
+ with gr.Group(elem_classes=["tt-panel"]):
1214
+ gr.Markdown("### Dispatch", elem_classes=["tt-panel-title"])
1215
+ enable_webhooks = gr.Checkbox(label="Enable webhook POSTs", value=False)
1216
+ webhook_url = gr.Textbox(label="Fallback webhook URL", value=DEFAULT_WEBHOOK_URL)
1217
+ with gr.Group(elem_classes=["tt-panel"]):
1218
+ gr.Markdown("### Demo State", elem_classes=["tt-panel-title"])
1219
+ demo_state_output = gr.HTML(
1220
+ _status_card("idle", "Ready for replay", "Cooldown memory is kept between automation runs."),
1221
+ elem_classes=["tt-demo-state"],
1222
+ )
1223
+ reset_state_button = gr.Button("Reset cooldowns and history", variant="secondary")
1224
+ demo_state_json = gr.JSON(label="State event", visible=False)
1225
+ with gr.Column(scale=4, min_width=320):
1226
+ with gr.Group(elem_classes=["tt-panel"]):
1227
+ gr.Markdown("### IP Camera Skeleton", elem_classes=["tt-panel-title"])
1228
+ gr.Markdown(
1229
+ "The MVP runs uploaded clips. This section reserves the next product path: a persistent camera feed that runs the same active automations on a schedule.",
1230
+ elem_classes=["tt-flow-copy"],
1231
+ )
1232
+ camera_url = gr.Textbox(label="Camera / RTSP URL", value=LOCAL_CONFIG.camera_url or "")
1233
+ poll_interval = gr.Slider(label="Polling interval seconds", minimum=1, maximum=60, step=1, value=5)
1234
+ gr.HTML(_status_card("idle", "Not active in MVP", "Prepared for continuous monitoring after the hackathon demo."))
1235
+
1236
+ monitor_inputs = [
1237
+ monitor_clip_input,
1238
+ rules_text,
1239
+ confidence,
1240
+ frame_stride,
1241
+ max_frames,
1242
+ model_name,
1243
+ image_size,
1244
+ device,
1245
+ max_detections,
1246
+ enable_webhooks,
1247
+ webhook_url,
1248
+ ]
1249
+ monitor_outputs = [
1250
+ automation_video,
1251
+ actions_table,
1252
+ automation_detections,
1253
+ automation_json,
1254
+ monitor_status,
1255
+ monitor_labels_output,
1256
+ ]
1257
+ generated_rule_outputs = [
1258
+ rules_text,
1259
+ compiled_json,
1260
+ rules_status,
1261
+ rules_labels_output,
1262
+ automation_table,
1263
+ managed_rule,
1264
+ managed_enabled,
1265
+ monitor_labels_output,
1266
+ ]
1267
+ tested_rule_outputs = [
1268
+ compiled_json,
1269
+ rules_status,
1270
+ rules_labels_output,
1271
+ automation_table,
1272
+ managed_rule,
1273
+ managed_enabled,
1274
+ monitor_labels_output,
1275
+ ]
1276
+ manager_refresh_outputs = [
1277
+ automation_table,
1278
+ managed_rule,
1279
+ managed_enabled,
1280
+ compiled_json,
1281
+ rules_status,
1282
+ rules_labels_output,
1283
+ monitor_labels_output,
1284
+ ]
1285
+ manager_apply_outputs = [
1286
+ rules_text,
1287
+ automation_table,
1288
+ managed_rule,
1289
+ managed_enabled,
1290
+ compiled_json,
1291
+ rules_status,
1292
+ rules_labels_output,
1293
+ monitor_labels_output,
1294
+ ]
1295
+ monitor_clip_input.change(run_monitor_clip, inputs=monitor_inputs, outputs=monitor_outputs)
1296
+ monitor_run_button.click(run_monitor_clip, inputs=monitor_inputs, outputs=monitor_outputs)
1297
+ compile_button.click(
1298
+ compile_rules,
1299
+ inputs=[instruction, rules_text, label_hints, llama_base_url, llama_model],
1300
+ outputs=generated_rule_outputs,
1301
+ )
1302
+ validate_rules_button.click(
1303
+ validate_rules,
1304
+ inputs=[rules_text],
1305
+ outputs=tested_rule_outputs,
1306
+ )
1307
+ save_rules_button.click(
1308
+ save_rules,
1309
+ inputs=[rules_text],
1310
+ outputs=generated_rule_outputs,
1311
+ )
1312
+ manager_save_button.click(save_rules, inputs=[rules_text], outputs=generated_rule_outputs)
1313
+ refresh_manager_button.click(refresh_automation_manager, inputs=[rules_text], outputs=manager_refresh_outputs)
1314
+ managed_rule.change(select_managed_rule, inputs=[rules_text, managed_rule], outputs=[managed_enabled, rules_status])
1315
+ apply_rule_button.click(
1316
+ set_rule_enabled,
1317
+ inputs=[rules_text, managed_rule, managed_enabled],
1318
+ outputs=manager_apply_outputs,
1319
+ )
1320
+ reset_state_button.click(reset_demo_state, outputs=[demo_state_output, demo_state_json])
1321
+
1322
+
1323
+ if __name__ == "__main__":
1324
+ demo.launch(ssr_mode=False, css=APP_CSS)
examples/feed-cat.yaml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ rules:
2
+ - name: feed-cat
3
+ when:
4
+ all:
5
+ - present: {label: cat, min_count: 1}
6
+ - near: {a: cat, b: feeder robot, max_distance: 0.35}
7
+ - cooldown: {key: feed-cat, minutes: 30}
8
+ then:
9
+ - type: webhook
10
+ name: feed cat
examples/turn-on-pc.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ rules:
2
+ - name: person-near-steering-wheel
3
+ when:
4
+ all:
5
+ - present: {label: person, min_count: 1}
6
+ - near: {a: person, b: steering wheel, max_distance: 0.45}
7
+ gate:
8
+ enabled: true
9
+ cooldown: {key: turn-on-pc, minutes: 5}
10
+ then:
11
+ - type: webhook
12
+ name: turn on pc
frontend/.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ *.local
4
+ .vite
frontend/index.html ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Tiny Trigger — Automation Console</title>
7
+ <meta name="color-scheme" content="dark" />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
11
+ <link
12
+ href="https://api.fontshare.com/v2/css?f[]=clash-display@600,500,700&display=swap"
13
+ rel="stylesheet"
14
+ />
15
+ <link
16
+ href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap"
17
+ rel="stylesheet"
18
+ />
19
+ </head>
20
+ <body>
21
+ <div id="root"></div>
22
+ <script type="module" src="/src/main.tsx"></script>
23
+ </body>
24
+ </html>
frontend/package.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "tiny-trigger-ui",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview",
10
+ "typecheck": "tsc -b --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@gradio/client": "^1.14.0",
14
+ "@radix-ui/react-label": "^2.1.2",
15
+ "@radix-ui/react-scroll-area": "^1.2.3",
16
+ "@radix-ui/react-separator": "^1.1.2",
17
+ "@radix-ui/react-slider": "^1.2.3",
18
+ "@radix-ui/react-slot": "^1.1.2",
19
+ "@radix-ui/react-tabs": "^1.1.3",
20
+ "@radix-ui/react-tooltip": "^1.1.8",
21
+ "class-variance-authority": "^0.7.1",
22
+ "clsx": "^2.1.1",
23
+ "lucide-react": "^0.475.0",
24
+ "motion": "^12.4.0",
25
+ "react": "^19.0.0",
26
+ "react-dom": "^19.0.0",
27
+ "sonner": "^2.0.0",
28
+ "tailwind-merge": "^3.0.1"
29
+ },
30
+ "devDependencies": {
31
+ "@tailwindcss/vite": "^4.0.6",
32
+ "@types/node": "^22.13.1",
33
+ "@types/react": "^19.0.8",
34
+ "@types/react-dom": "^19.0.3",
35
+ "@vitejs/plugin-react": "^4.3.4",
36
+ "tailwindcss": "^4.0.6",
37
+ "tw-animate-css": "^1.2.4",
38
+ "typescript": "^5.7.3",
39
+ "vite": "^6.1.0"
40
+ }
41
+ }
frontend/pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
frontend/pnpm-workspace.yaml ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ allowBuilds:
2
+ bufferutil: false
3
+ esbuild: true
4
+ msw: false
frontend/src/App.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DashboardProvider } from "@/lib/dashboard"
2
+ import { Sidebar } from "@/components/layout/Sidebar"
3
+ import { TopBar } from "@/components/layout/TopBar"
4
+ import { Section } from "@/components/layout/Section"
5
+ import { ModulePanel } from "@/components/layout/ModulePanel"
6
+ import { Toaster } from "@/components/ui/sonner"
7
+ import { SECTIONS, SPAN_CLASS, modulesForSection } from "@/modules/registry"
8
+
9
+ export default function App() {
10
+ let delay = 0
11
+ const nextDelay = () => `${(delay++ * 60).toString()}ms`
12
+
13
+ return (
14
+ <DashboardProvider>
15
+ <div className="flex min-h-screen">
16
+ <Sidebar />
17
+ <div className="flex min-w-0 flex-1 flex-col">
18
+ <TopBar />
19
+ <main className="mx-auto w-full max-w-[1500px] flex-1 space-y-10 px-4 py-8 sm:px-6">
20
+ {SECTIONS.sort((a, b) => a.order - b.order).map((section) => (
21
+ <Section key={section.id} section={section}>
22
+ {modulesForSection(section.id).map((m) => (
23
+ <ModulePanel
24
+ key={m.id}
25
+ module={m}
26
+ className={SPAN_CLASS[m.span]}
27
+ style={{ animationDelay: nextDelay() }}
28
+ />
29
+ ))}
30
+ </Section>
31
+ ))}
32
+
33
+ <footer className="flex items-center justify-between border-t border-border/60 pt-4 font-mono text-[0.625rem] uppercase tracking-wider text-muted-foreground/50">
34
+ <span>tiny trigger · gradio.server backend</span>
35
+ <span>rules are data, never code</span>
36
+ </footer>
37
+ </main>
38
+ </div>
39
+ </div>
40
+ <Toaster />
41
+ </DashboardProvider>
42
+ )
43
+ }
frontend/src/components/layout/ModulePanel.tsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, CardContent, CardHeader } from "@/components/ui/card"
2
+ import { cn } from "@/lib/utils"
3
+ import type { DashboardModule } from "@/modules/registry"
4
+
5
+ export function ModulePanel({
6
+ module,
7
+ style,
8
+ className,
9
+ }: {
10
+ module: DashboardModule
11
+ style?: React.CSSProperties
12
+ className?: string
13
+ }) {
14
+ const { icon: Icon, Panel } = module
15
+ return (
16
+ <Card id={module.id} className={cn("reveal scroll-mt-24", className)} style={style}>
17
+ <CardHeader className="border-b border-border/60">
18
+ <div className="flex items-center gap-2.5">
19
+ <span className="flex size-7 items-center justify-center rounded-md border border-border bg-white/5 text-muted-foreground">
20
+ <Icon className="size-3.5" />
21
+ </span>
22
+ <div className="leading-tight">
23
+ <div className="font-mono text-[0.625rem] uppercase tracking-[0.18em] text-muted-foreground">
24
+ {module.eyebrow}
25
+ </div>
26
+ <h2 className="font-display text-sm font-semibold tracking-tight text-foreground">
27
+ {module.title}
28
+ </h2>
29
+ </div>
30
+ </div>
31
+ <span className="font-mono text-xs tabular-nums text-muted-foreground/50">
32
+ {module.index}
33
+ </span>
34
+ </CardHeader>
35
+ <CardContent className="pt-4">
36
+ <Panel />
37
+ </CardContent>
38
+ </Card>
39
+ )
40
+ }
frontend/src/components/layout/Section.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react"
2
+ import type { DashboardSection } from "@/modules/registry"
3
+
4
+ export function Section({
5
+ section,
6
+ children,
7
+ }: {
8
+ section: DashboardSection
9
+ children: ReactNode
10
+ }) {
11
+ return (
12
+ <section id={section.id} className="scroll-mt-20">
13
+ {/* labeled horizontal separator */}
14
+ <div className="mb-5 flex items-center gap-4">
15
+ <div className="flex items-baseline gap-2.5">
16
+ <span className="font-mono text-[0.625rem] tabular-nums text-primary/70">
17
+ {String(section.order).padStart(2, "0")}
18
+ </span>
19
+ <h2 className="font-display text-base font-semibold tracking-tight text-foreground">
20
+ {section.label}
21
+ </h2>
22
+ <span className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground">
23
+ {section.hint}
24
+ </span>
25
+ </div>
26
+ <div className="h-px flex-1 hairline" />
27
+ </div>
28
+
29
+ <div className="grid grid-cols-1 gap-5 lg:grid-cols-12">{children}</div>
30
+ </section>
31
+ )
32
+ }
frontend/src/components/layout/Sidebar.tsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ListChecks, SlidersHorizontal, TestTube, Zap } from "lucide-react"
2
+ import type { LucideIcon } from "lucide-react"
3
+ import { SECTIONS, type SectionId } from "@/modules/registry"
4
+ import {
5
+ Tooltip,
6
+ TooltipContent,
7
+ TooltipProvider,
8
+ TooltipTrigger,
9
+ } from "@/components/ui/tooltip"
10
+
11
+ const SECTION_ICON: Record<SectionId, LucideIcon> = {
12
+ test: TestTube,
13
+ automation: ListChecks,
14
+ settings: SlidersHorizontal,
15
+ }
16
+
17
+ export function Sidebar() {
18
+ return (
19
+ <aside className="sticky top-0 hidden h-screen w-16 shrink-0 flex-col items-center border-r border-border/60 bg-card/40 py-4 backdrop-blur-sm md:flex">
20
+ <a
21
+ href="#top"
22
+ className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-[0_8px_24px_-12px_oklch(0.885_0.19_122_/_0.8)]"
23
+ aria-label="Tiny Trigger"
24
+ >
25
+ <Zap className="size-5" />
26
+ </a>
27
+
28
+ <nav className="mt-6 flex flex-1 flex-col items-center gap-1.5">
29
+ <TooltipProvider delayDuration={200}>
30
+ {SECTIONS.map((s) => {
31
+ const Icon = SECTION_ICON[s.id]
32
+ return (
33
+ <Tooltip key={s.id}>
34
+ <TooltipTrigger asChild>
35
+ <a
36
+ href={`#${s.id}`}
37
+ className="group flex size-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/5 hover:text-foreground"
38
+ >
39
+ <Icon className="size-4" />
40
+ </a>
41
+ </TooltipTrigger>
42
+ <TooltipContent side="right">{s.label}</TooltipContent>
43
+ </Tooltip>
44
+ )
45
+ })}
46
+ </TooltipProvider>
47
+ </nav>
48
+
49
+ <div className="font-mono text-[0.5rem] uppercase tracking-[0.2em] text-muted-foreground/50 [writing-mode:vertical-rl]">
50
+ local-first
51
+ </div>
52
+ </aside>
53
+ )
54
+ }
frontend/src/components/layout/TopBar.tsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useDashboard } from "@/lib/dashboard"
2
+ import { Badge } from "@/components/ui/badge"
3
+
4
+ export function TopBar() {
5
+ const { params, result, running } = useDashboard()
6
+
7
+ return (
8
+ <header
9
+ id="top"
10
+ className="sticky top-0 z-30 border-b border-border/60 bg-background/70 backdrop-blur-xl"
11
+ >
12
+ <div className="flex flex-wrap items-center justify-between gap-3 px-6 py-4">
13
+ <div>
14
+ <div className="flex items-center gap-2.5">
15
+ <h1 className="font-display text-xl font-semibold tracking-tight">
16
+ Tiny&nbsp;Trigger
17
+ </h1>
18
+ <Badge variant={running ? "live" : "default"}>
19
+ {running ? (
20
+ <>
21
+ <span className="size-1.5 rounded-full bg-primary signal-dot" />
22
+ running
23
+ </>
24
+ ) : (
25
+ "idle"
26
+ )}
27
+ </Badge>
28
+ </div>
29
+ <p className="mt-0.5 text-xs text-muted-foreground">
30
+ Open-vocabulary video automations · detect → validate rules → dispatch
31
+ </p>
32
+ </div>
33
+
34
+ <div className="flex flex-wrap items-center gap-2">
35
+ <Badge>{params.modelName}</Badge>
36
+ <Badge variant={params.enableWebhooks ? "info" : "default"}>
37
+ webhooks {params.enableWebhooks ? "live" : "off"}
38
+ </Badge>
39
+ {result && (
40
+ <Badge variant={result.status === "fired" ? "live" : "warning"}>
41
+ {result.status === "fired" ? `${result.stats.actions} fired` : "no match"}
42
+ </Badge>
43
+ )}
44
+ </div>
45
+ </div>
46
+ </header>
47
+ )
48
+ }
frontend/src/components/ui/badge.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const badgeVariants = cva(
6
+ "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[0.6875rem] font-medium font-mono uppercase tracking-wider transition-colors",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: "border-border bg-white/5 text-muted-foreground",
11
+ live: "border-primary/40 bg-primary/10 text-primary",
12
+ warning: "border-warning/40 bg-warning/10 text-warning",
13
+ info: "border-info/40 bg-info/10 text-info",
14
+ danger: "border-destructive/40 bg-destructive/10 text-destructive",
15
+ },
16
+ },
17
+ defaultVariants: { variant: "default" },
18
+ },
19
+ )
20
+
21
+ function Badge({
22
+ className,
23
+ variant,
24
+ ...props
25
+ }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
26
+ return <span className={cn(badgeVariants({ variant }), className)} {...props} />
27
+ }
28
+
29
+ export { Badge, badgeVariants }
frontend/src/components/ui/button.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const buttonVariants = cva(
7
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98] select-none",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "bg-primary text-primary-foreground font-semibold shadow-[0_0_0_1px_oklch(0.885_0.19_122_/_0.4),0_8px_24px_-12px_oklch(0.885_0.19_122_/_0.6)] hover:brightness-110",
13
+ secondary:
14
+ "bg-secondary text-secondary-foreground border border-border hover:bg-accent hover:border-white/15",
15
+ ghost: "text-muted-foreground hover:text-foreground hover:bg-white/5",
16
+ outline:
17
+ "border border-border bg-transparent hover:bg-white/5 hover:text-foreground",
18
+ destructive:
19
+ "bg-destructive/15 text-destructive border border-destructive/40 hover:bg-destructive/25",
20
+ },
21
+ size: {
22
+ default: "h-9 px-4 py-2",
23
+ sm: "h-8 rounded-md px-3 text-xs",
24
+ lg: "h-11 rounded-lg px-6 text-[0.95rem]",
25
+ icon: "size-9",
26
+ },
27
+ },
28
+ defaultVariants: { variant: "default", size: "default" },
29
+ },
30
+ )
31
+
32
+ function Button({
33
+ className,
34
+ variant,
35
+ size,
36
+ asChild = false,
37
+ ...props
38
+ }: React.ComponentProps<"button"> &
39
+ VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
40
+ const Comp = asChild ? Slot : "button"
41
+ return (
42
+ <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />
43
+ )
44
+ }
45
+
46
+ export { Button, buttonVariants }
frontend/src/components/ui/card.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ function Card({ className, ...props }: React.ComponentProps<"div">) {
5
+ return (
6
+ <div
7
+ className={cn(
8
+ "relative rounded-xl border border-border bg-card/80 backdrop-blur-sm",
9
+ "shadow-[0_1px_0_0_oklch(1_0_0_/_0.04)_inset,0_24px_48px_-32px_oklch(0_0_0_/_0.8)]",
10
+ className,
11
+ )}
12
+ {...props}
13
+ />
14
+ )
15
+ }
16
+
17
+ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
18
+ return (
19
+ <div
20
+ className={cn(
21
+ "flex items-center justify-between gap-3 px-5 pt-4 pb-3",
22
+ className,
23
+ )}
24
+ {...props}
25
+ />
26
+ )
27
+ }
28
+
29
+ function CardEyebrow({ className, ...props }: React.ComponentProps<"div">) {
30
+ return (
31
+ <div
32
+ className={cn(
33
+ "font-mono text-[0.6875rem] font-medium uppercase tracking-[0.18em] text-muted-foreground",
34
+ className,
35
+ )}
36
+ {...props}
37
+ />
38
+ )
39
+ }
40
+
41
+ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
42
+ return <div className={cn("px-5 pb-5", className)} {...props} />
43
+ }
44
+
45
+ export { Card, CardHeader, CardEyebrow, CardContent }
frontend/src/components/ui/input.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
5
+ return (
6
+ <input
7
+ type={type}
8
+ className={cn(
9
+ "flex h-9 w-full rounded-md border border-input bg-black/20 px-3 py-1 text-sm",
10
+ "transition-colors placeholder:text-muted-foreground/60",
11
+ "focus-visible:outline-none focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-ring",
12
+ "disabled:cursor-not-allowed disabled:opacity-50",
13
+ className,
14
+ )}
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+
20
+ export { Input }
frontend/src/components/ui/label.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as LabelPrimitive from "@radix-ui/react-label"
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Label({
6
+ className,
7
+ ...props
8
+ }: React.ComponentProps<typeof LabelPrimitive.Root>) {
9
+ return (
10
+ <LabelPrimitive.Root
11
+ className={cn(
12
+ "text-xs font-medium text-muted-foreground select-none",
13
+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
14
+ className,
15
+ )}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ export { Label }
frontend/src/components/ui/separator.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as SeparatorPrimitive from "@radix-ui/react-separator"
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Separator({
6
+ className,
7
+ orientation = "horizontal",
8
+ decorative = true,
9
+ ...props
10
+ }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
11
+ return (
12
+ <SeparatorPrimitive.Root
13
+ decorative={decorative}
14
+ orientation={orientation}
15
+ className={cn(
16
+ "shrink-0 bg-border",
17
+ orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
18
+ className,
19
+ )}
20
+ {...props}
21
+ />
22
+ )
23
+ }
24
+
25
+ export { Separator }
frontend/src/components/ui/slider.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as SliderPrimitive from "@radix-ui/react-slider"
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Slider({
6
+ className,
7
+ ...props
8
+ }: React.ComponentProps<typeof SliderPrimitive.Root>) {
9
+ return (
10
+ <SliderPrimitive.Root
11
+ className={cn(
12
+ "relative flex w-full touch-none select-none items-center py-1.5",
13
+ className,
14
+ )}
15
+ {...props}
16
+ >
17
+ <SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-white/10">
18
+ <SliderPrimitive.Range className="absolute h-full bg-primary" />
19
+ </SliderPrimitive.Track>
20
+ <SliderPrimitive.Thumb
21
+ className={cn(
22
+ "block size-3.5 rounded-full border border-primary bg-background shadow-[0_0_0_3px_oklch(0.885_0.19_122_/_0.2)]",
23
+ "transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
24
+ )}
25
+ />
26
+ </SliderPrimitive.Root>
27
+ )
28
+ }
29
+
30
+ export { Slider }
frontend/src/components/ui/sonner.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Toaster as Sonner } from "sonner"
2
+
3
+ function Toaster() {
4
+ return (
5
+ <Sonner
6
+ theme="dark"
7
+ position="bottom-right"
8
+ toastOptions={{
9
+ classNames: {
10
+ toast:
11
+ "!bg-popover !border-border !text-foreground !rounded-lg !font-sans !shadow-2xl",
12
+ description: "!text-muted-foreground",
13
+ actionButton: "!bg-primary !text-primary-foreground",
14
+ },
15
+ }}
16
+ />
17
+ )
18
+ }
19
+
20
+ export { Toaster }
frontend/src/components/ui/switch.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cn } from "@/lib/utils"
2
+
3
+ export function Switch({
4
+ checked,
5
+ onCheckedChange,
6
+ id,
7
+ }: {
8
+ checked: boolean
9
+ onCheckedChange: (v: boolean) => void
10
+ id?: string
11
+ }) {
12
+ return (
13
+ <button
14
+ id={id}
15
+ role="switch"
16
+ aria-checked={checked}
17
+ onClick={() => onCheckedChange(!checked)}
18
+ className={cn(
19
+ "relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
20
+ checked ? "border-primary/50 bg-primary/30" : "border-border bg-white/5",
21
+ )}
22
+ >
23
+ <span
24
+ className={cn(
25
+ "ml-0.5 size-3.5 rounded-full transition-transform",
26
+ checked ? "translate-x-4 bg-primary" : "translate-x-0 bg-muted-foreground",
27
+ )}
28
+ />
29
+ </button>
30
+ )
31
+ }
frontend/src/components/ui/table.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ function Table({ className, ...props }: React.ComponentProps<"table">) {
5
+ return (
6
+ <div className="relative w-full overflow-x-auto">
7
+ <table className={cn("w-full caption-bottom text-sm", className)} {...props} />
8
+ </div>
9
+ )
10
+ }
11
+
12
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
13
+ return <thead className={cn("", className)} {...props} />
14
+ }
15
+
16
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
17
+ return <tbody className={cn("", className)} {...props} />
18
+ }
19
+
20
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
21
+ return (
22
+ <tr
23
+ className={cn(
24
+ "border-b border-border/60 transition-colors hover:bg-white/[0.025]",
25
+ className,
26
+ )}
27
+ {...props}
28
+ />
29
+ )
30
+ }
31
+
32
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
33
+ return (
34
+ <th
35
+ className={cn(
36
+ "h-9 px-3 text-left align-middle font-mono text-[0.6875rem] font-medium uppercase tracking-wider text-muted-foreground",
37
+ className,
38
+ )}
39
+ {...props}
40
+ />
41
+ )
42
+ }
43
+
44
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
45
+ return (
46
+ <td className={cn("px-3 py-2.5 align-middle", className)} {...props} />
47
+ )
48
+ }
49
+
50
+ export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
frontend/src/components/ui/tabs.tsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as TabsPrimitive from "@radix-ui/react-tabs"
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const Tabs = TabsPrimitive.Root
6
+
7
+ function TabsList({
8
+ className,
9
+ ...props
10
+ }: React.ComponentProps<typeof TabsPrimitive.List>) {
11
+ return (
12
+ <TabsPrimitive.List
13
+ className={cn(
14
+ "inline-flex h-9 items-center gap-1 rounded-lg border border-border bg-black/20 p-1",
15
+ className,
16
+ )}
17
+ {...props}
18
+ />
19
+ )
20
+ }
21
+
22
+ function TabsTrigger({
23
+ className,
24
+ ...props
25
+ }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
26
+ return (
27
+ <TabsPrimitive.Trigger
28
+ className={cn(
29
+ "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-all",
30
+ "text-muted-foreground hover:text-foreground",
31
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
32
+ "data-[state=active]:bg-secondary data-[state=active]:text-foreground data-[state=active]:shadow-sm",
33
+ className,
34
+ )}
35
+ {...props}
36
+ />
37
+ )
38
+ }
39
+
40
+ function TabsContent({
41
+ className,
42
+ ...props
43
+ }: React.ComponentProps<typeof TabsPrimitive.Content>) {
44
+ return (
45
+ <TabsPrimitive.Content
46
+ className={cn("mt-4 focus-visible:outline-none", className)}
47
+ {...props}
48
+ />
49
+ )
50
+ }
51
+
52
+ export { Tabs, TabsList, TabsTrigger, TabsContent }
frontend/src/components/ui/textarea.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
5
+ return (
6
+ <textarea
7
+ className={cn(
8
+ "flex min-h-20 w-full rounded-md border border-input bg-black/20 px-3 py-2 text-sm",
9
+ "transition-colors placeholder:text-muted-foreground/60 resize-none",
10
+ "focus-visible:outline-none focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-ring",
11
+ "disabled:cursor-not-allowed disabled:opacity-50",
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ )
17
+ }
18
+
19
+ export { Textarea }
frontend/src/components/ui/tooltip.tsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const TooltipProvider = TooltipPrimitive.Provider
6
+ const Tooltip = TooltipPrimitive.Root
7
+ const TooltipTrigger = TooltipPrimitive.Trigger
8
+
9
+ function TooltipContent({
10
+ className,
11
+ sideOffset = 6,
12
+ ...props
13
+ }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
14
+ return (
15
+ <TooltipPrimitive.Portal>
16
+ <TooltipPrimitive.Content
17
+ sideOffset={sideOffset}
18
+ className={cn(
19
+ "z-50 overflow-hidden rounded-md border border-border bg-popover px-2.5 py-1 text-xs text-popover-foreground shadow-xl",
20
+ "animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
21
+ className,
22
+ )}
23
+ {...props}
24
+ />
25
+ </TooltipPrimitive.Portal>
26
+ )
27
+ }
28
+
29
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
frontend/src/lib/dashboard.tsx ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react"
10
+ import type { ReactNode } from "react"
11
+ import { toast } from "sonner"
12
+ import * as api from "./api"
13
+ import type {
14
+ CompileResult,
15
+ DetectParams,
16
+ RunResult,
17
+ ValidationResult,
18
+ } from "./types"
19
+
20
+ const DEFAULT_CLASSES = "person, steering wheel, monitor, chair, bag, sticker"
21
+
22
+ const DEFAULT_RULES = `rules:
23
+ - name: person-near-steering-wheel
24
+ when:
25
+ all:
26
+ - present: {label: person, min_count: 1}
27
+ - near: {a: person, b: steering wheel, max_distance: 0.45}
28
+ gate:
29
+ enabled: true
30
+ cooldown: {key: turn-on-pc, minutes: 5}
31
+ then:
32
+ - type: simulate
33
+ name: turn on pc
34
+ `
35
+
36
+ const DEFAULT_PARAMS: DetectParams = {
37
+ classes: DEFAULT_CLASSES,
38
+ confidence: 0.25,
39
+ frameStride: 5,
40
+ maxFrames: 120,
41
+ modelName: "yoloe-26s-seg.pt",
42
+ imageSize: 0,
43
+ device: "auto",
44
+ maxDetections: 0,
45
+ enableWebhooks: false,
46
+ webhookUrl: "",
47
+ }
48
+
49
+ interface DashboardState {
50
+ params: DetectParams
51
+ setParam: <K extends keyof DetectParams>(key: K, value: DetectParams[K]) => void
52
+ rulesText: string
53
+ setRulesText: (text: string) => void
54
+ videoFile: File | null
55
+ videoPreviewUrl: string | null
56
+ setVideo: (file: File | null) => void
57
+ result: RunResult | null
58
+ running: boolean
59
+ error: string | null
60
+ run: () => Promise<void>
61
+ validation: ValidationResult | null
62
+ validate: () => Promise<void>
63
+ save: () => Promise<void>
64
+ compile: (instruction: string) => Promise<CompileResult | null>
65
+ compiling: boolean
66
+ }
67
+
68
+ const Ctx = createContext<DashboardState | null>(null)
69
+
70
+ export function DashboardProvider({ children }: { children: ReactNode }) {
71
+ const [params, setParams] = useState<DetectParams>(DEFAULT_PARAMS)
72
+ const [rulesText, setRulesText] = useState<string>(DEFAULT_RULES)
73
+ const [videoFile, setVideoFile] = useState<File | null>(null)
74
+ const [videoPreviewUrl, setVideoPreviewUrl] = useState<string | null>(null)
75
+ const [result, setResult] = useState<RunResult | null>(null)
76
+ const [running, setRunning] = useState(false)
77
+ const [error, setError] = useState<string | null>(null)
78
+ const [validation, setValidation] = useState<ValidationResult | null>(null)
79
+ const [compiling, setCompiling] = useState(false)
80
+ const previewRef = useRef<string | null>(null)
81
+
82
+ // Hydrate defaults from backend config + saved rules on mount.
83
+ useEffect(() => {
84
+ api
85
+ .getConfig()
86
+ .then((cfg) => {
87
+ setParams((p) => ({
88
+ ...p,
89
+ classes: cfg.default_classes ?? p.classes,
90
+ modelName: cfg.default_detector_model ?? p.modelName,
91
+ device: cfg.default_device ?? p.device,
92
+ imageSize: cfg.default_image_size ?? p.imageSize,
93
+ maxDetections: cfg.default_max_detections ?? p.maxDetections,
94
+ webhookUrl: cfg.webhook_url ?? p.webhookUrl,
95
+ }))
96
+ })
97
+ .catch(() => void 0)
98
+ api
99
+ .loadRules()
100
+ .then((r) => r.rules_text && setRulesText(r.rules_text))
101
+ .catch(() => void 0)
102
+ }, [])
103
+
104
+ const setParam = useCallback(
105
+ <K extends keyof DetectParams>(key: K, value: DetectParams[K]) => {
106
+ setParams((p) => ({ ...p, [key]: value }))
107
+ },
108
+ [],
109
+ )
110
+
111
+ const setVideo = useCallback((file: File | null) => {
112
+ if (previewRef.current) URL.revokeObjectURL(previewRef.current)
113
+ if (file) {
114
+ const url = URL.createObjectURL(file)
115
+ previewRef.current = url
116
+ setVideoPreviewUrl(url)
117
+ } else {
118
+ previewRef.current = null
119
+ setVideoPreviewUrl(null)
120
+ }
121
+ setVideoFile(file)
122
+ }, [])
123
+
124
+ const run = useCallback(async () => {
125
+ if (!videoFile) {
126
+ toast.error("Upload a video first")
127
+ return
128
+ }
129
+ setRunning(true)
130
+ setError(null)
131
+ try {
132
+ const res = await api.detectAndAutomate(videoFile, rulesText, params)
133
+ setResult(res)
134
+ if (res.status === "fired") {
135
+ toast.success(`${res.stats.actions} action${res.stats.actions === 1 ? "" : "s"} fired`)
136
+ } else {
137
+ toast.message("No frame matched the automation", {
138
+ description: `${res.stats.detections} detections across ${res.stats.processed_frames} frames`,
139
+ })
140
+ }
141
+ } catch (e) {
142
+ const message = e instanceof Error ? e.message : String(e)
143
+ setError(message)
144
+ toast.error("Run failed", { description: message })
145
+ } finally {
146
+ setRunning(false)
147
+ }
148
+ }, [videoFile, rulesText, params])
149
+
150
+ const validate = useCallback(async () => {
151
+ try {
152
+ const v = await api.validateRules(rulesText)
153
+ setValidation(v)
154
+ if (v.ok) toast.success(`Validated — ${v.rules.length} rule${v.rules.length === 1 ? "" : "s"}`)
155
+ else toast.error("Validation failed")
156
+ } catch (e) {
157
+ toast.error("Validation error", {
158
+ description: e instanceof Error ? e.message : String(e),
159
+ })
160
+ }
161
+ }, [rulesText])
162
+
163
+ const save = useCallback(async () => {
164
+ try {
165
+ const r = await api.saveRules(rulesText)
166
+ toast.success(`Saved ${r.rule_count} rule${r.rule_count === 1 ? "" : "s"}`)
167
+ } catch (e) {
168
+ toast.error("Save failed", {
169
+ description: e instanceof Error ? e.message : String(e),
170
+ })
171
+ }
172
+ }, [rulesText])
173
+
174
+ const compile = useCallback(
175
+ async (instruction: string): Promise<CompileResult | null> => {
176
+ setCompiling(true)
177
+ try {
178
+ const c = await api.compileRules(
179
+ instruction,
180
+ params.classes,
181
+ "http://127.0.0.1:8080/v1",
182
+ "ggml-org/Qwen3-1.7B-GGUF:Q4_K_M",
183
+ )
184
+ setRulesText(c.rules_text)
185
+ toast.success(`Compiled ${c.rule_count} rule${c.rule_count === 1 ? "" : "s"}`)
186
+ return c
187
+ } catch (e) {
188
+ toast.error("Compile failed", {
189
+ description: e instanceof Error ? e.message : String(e),
190
+ })
191
+ return null
192
+ } finally {
193
+ setCompiling(false)
194
+ }
195
+ },
196
+ [params.classes],
197
+ )
198
+
199
+ const value = useMemo<DashboardState>(
200
+ () => ({
201
+ params,
202
+ setParam,
203
+ rulesText,
204
+ setRulesText,
205
+ videoFile,
206
+ videoPreviewUrl,
207
+ setVideo,
208
+ result,
209
+ running,
210
+ error,
211
+ run,
212
+ validation,
213
+ validate,
214
+ save,
215
+ compile,
216
+ compiling,
217
+ }),
218
+ [
219
+ params,
220
+ setParam,
221
+ rulesText,
222
+ videoFile,
223
+ videoPreviewUrl,
224
+ setVideo,
225
+ result,
226
+ running,
227
+ error,
228
+ run,
229
+ validation,
230
+ validate,
231
+ save,
232
+ compile,
233
+ compiling,
234
+ ],
235
+ )
236
+
237
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>
238
+ }
239
+
240
+ export function useDashboard(): DashboardState {
241
+ const ctx = useContext(Ctx)
242
+ if (!ctx) throw new Error("useDashboard must be used within DashboardProvider")
243
+ return ctx
244
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from "react"
2
+ import { createRoot } from "react-dom/client"
3
+ import "@/styles/index.css"
4
+ import App from "@/App"
5
+
6
+ createRoot(document.getElementById("root")!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
frontend/src/modules/activity/ActivityPanel.tsx ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo } from "react"
2
+ import { Inbox } from "lucide-react"
3
+ import { useDashboard } from "@/lib/dashboard"
4
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
5
+ import {
6
+ Table,
7
+ TableBody,
8
+ TableCell,
9
+ TableHead,
10
+ TableHeader,
11
+ TableRow,
12
+ } from "@/components/ui/table"
13
+ import { Badge } from "@/components/ui/badge"
14
+
15
+ // deterministic label → hue (matches the spirit of the backend's md5 colouring)
16
+ function hueFor(label: string): number {
17
+ let h = 0
18
+ for (let i = 0; i < label.length; i++) h = (h * 31 + label.charCodeAt(i)) % 360
19
+ return h
20
+ }
21
+
22
+ function StatusBadge({ status }: { status: string }) {
23
+ const variant = status.includes("fail") || status.includes("missing")
24
+ ? "danger"
25
+ : status.includes("posted")
26
+ ? "info"
27
+ : status.includes("block")
28
+ ? "warning"
29
+ : "live"
30
+ return <Badge variant={variant}>{status}</Badge>
31
+ }
32
+
33
+ export function ActivityPanel() {
34
+ const { result } = useDashboard()
35
+ const detections = result?.detections ?? []
36
+ const events = result?.events ?? []
37
+
38
+ const maxT = useMemo(
39
+ () =>
40
+ Math.max(
41
+ 0.001,
42
+ ...detections.map((d) => d.timestamp_sec),
43
+ ...events.map((e) => e.timestamp_sec),
44
+ ),
45
+ [detections, events],
46
+ )
47
+
48
+ const labels = useMemo(
49
+ () => Array.from(new Set(detections.map((d) => d.label))),
50
+ [detections],
51
+ )
52
+
53
+ if (!result) {
54
+ return (
55
+ <div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
56
+ <Inbox className="size-7 opacity-40" />
57
+ <p className="text-sm">Run detection to populate the activity timeline.</p>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ return (
63
+ <div className="space-y-5">
64
+ {/* timeline track */}
65
+ <div className="space-y-2">
66
+ <div className="flex items-center justify-between font-mono text-[0.625rem] uppercase tracking-wider text-muted-foreground">
67
+ <span>0.0s</span>
68
+ <span>detection timeline</span>
69
+ <span>{maxT.toFixed(1)}s</span>
70
+ </div>
71
+ <div className="relative h-16 overflow-hidden rounded-lg border border-border bg-black/30">
72
+ {/* gridlines */}
73
+ {[0.25, 0.5, 0.75].map((f) => (
74
+ <div
75
+ key={f}
76
+ className="absolute inset-y-0 w-px bg-white/5"
77
+ style={{ left: `${f * 100}%` }}
78
+ />
79
+ ))}
80
+ {/* detection ticks */}
81
+ {detections.map((d, i) => (
82
+ <span
83
+ key={i}
84
+ className="absolute top-1/2 size-1.5 -translate-y-1/2 rounded-full"
85
+ style={{
86
+ left: `${(d.timestamp_sec / maxT) * 100}%`,
87
+ backgroundColor: `oklch(0.75 0.13 ${hueFor(d.label)})`,
88
+ opacity: 0.25 + d.confidence * 0.75,
89
+ }}
90
+ />
91
+ ))}
92
+ {/* event markers */}
93
+ {events.map((e, i) => (
94
+ <div
95
+ key={i}
96
+ className="absolute inset-y-0 w-0.5 bg-primary"
97
+ style={{ left: `${(e.timestamp_sec / maxT) * 100}%` }}
98
+ >
99
+ <span className="absolute -top-0 left-1/2 size-2 -translate-x-1/2 rounded-full bg-primary signal-dot" />
100
+ </div>
101
+ ))}
102
+ </div>
103
+ {/* legend */}
104
+ <div className="flex flex-wrap gap-x-4 gap-y-1">
105
+ {labels.map((l) => (
106
+ <div key={l} className="flex items-center gap-1.5 text-[0.6875rem] text-muted-foreground">
107
+ <span
108
+ className="size-2 rounded-full"
109
+ style={{ backgroundColor: `oklch(0.75 0.13 ${hueFor(l)})` }}
110
+ />
111
+ {l}
112
+ </div>
113
+ ))}
114
+ </div>
115
+ </div>
116
+
117
+ {/* tables */}
118
+ <Tabs defaultValue="actions">
119
+ <TabsList>
120
+ <TabsTrigger value="actions">
121
+ Actions
122
+ <span className="ml-1 font-mono text-[0.625rem] text-muted-foreground">
123
+ {events.length}
124
+ </span>
125
+ </TabsTrigger>
126
+ <TabsTrigger value="detections">
127
+ Detections
128
+ <span className="ml-1 font-mono text-[0.625rem] text-muted-foreground">
129
+ {detections.length}
130
+ </span>
131
+ </TabsTrigger>
132
+ </TabsList>
133
+
134
+ <TabsContent value="actions">
135
+ {events.length ? (
136
+ <Table>
137
+ <TableHeader>
138
+ <TableRow>
139
+ <TableHead>Time</TableHead>
140
+ <TableHead>Rule</TableHead>
141
+ <TableHead>Action</TableHead>
142
+ <TableHead>Status</TableHead>
143
+ <TableHead className="text-right">Frame</TableHead>
144
+ </TableRow>
145
+ </TableHeader>
146
+ <TableBody>
147
+ {events.map((e, i) => (
148
+ <TableRow key={i}>
149
+ <TableCell className="font-mono text-xs tabular-nums text-muted-foreground">
150
+ {e.timestamp_sec.toFixed(1)}s
151
+ </TableCell>
152
+ <TableCell className="font-mono text-xs">{e.rule}</TableCell>
153
+ <TableCell className="text-xs">{e.action}</TableCell>
154
+ <TableCell>
155
+ <StatusBadge status={e.status} />
156
+ </TableCell>
157
+ <TableCell className="text-right font-mono text-xs tabular-nums text-muted-foreground">
158
+ {e.frame_index}
159
+ </TableCell>
160
+ </TableRow>
161
+ ))}
162
+ </TableBody>
163
+ </Table>
164
+ ) : (
165
+ <p className="py-6 text-center text-sm text-muted-foreground">
166
+ No actions fired this run.
167
+ </p>
168
+ )}
169
+ </TabsContent>
170
+
171
+ <TabsContent value="detections">
172
+ <div className="max-h-72 overflow-y-auto">
173
+ <Table>
174
+ <TableHeader>
175
+ <TableRow>
176
+ <TableHead className="text-right">Frame</TableHead>
177
+ <TableHead>Label</TableHead>
178
+ <TableHead className="text-right">Conf.</TableHead>
179
+ <TableHead className="text-right">Time</TableHead>
180
+ </TableRow>
181
+ </TableHeader>
182
+ <TableBody>
183
+ {detections.slice(0, 200).map((d, i) => (
184
+ <TableRow key={i}>
185
+ <TableCell className="text-right font-mono text-xs tabular-nums text-muted-foreground">
186
+ {d.frame_index}
187
+ </TableCell>
188
+ <TableCell>
189
+ <span className="flex items-center gap-1.5 text-xs">
190
+ <span
191
+ className="size-2 rounded-full"
192
+ style={{ backgroundColor: `oklch(0.75 0.13 ${hueFor(d.label)})` }}
193
+ />
194
+ {d.label}
195
+ </span>
196
+ </TableCell>
197
+ <TableCell className="text-right font-mono text-xs tabular-nums">
198
+ {d.confidence.toFixed(2)}
199
+ </TableCell>
200
+ <TableCell className="text-right font-mono text-xs tabular-nums text-muted-foreground">
201
+ {d.timestamp_sec.toFixed(1)}s
202
+ </TableCell>
203
+ </TableRow>
204
+ ))}
205
+ </TableBody>
206
+ </Table>
207
+ </div>
208
+ </TabsContent>
209
+ </Tabs>
210
+ </div>
211
+ )
212
+ }
frontend/src/modules/detector/DetectorPanel.tsx ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useState } from "react"
2
+ import { ChevronDown, Play, RotateCcw, Upload, Loader2 } from "lucide-react"
3
+ import { useDashboard } from "@/lib/dashboard"
4
+ import { Button } from "@/components/ui/button"
5
+ import { Label } from "@/components/ui/label"
6
+ import { Input } from "@/components/ui/input"
7
+ import { Textarea } from "@/components/ui/textarea"
8
+ import { Slider } from "@/components/ui/slider"
9
+ import { Separator } from "@/components/ui/separator"
10
+ import { cn } from "@/lib/utils"
11
+
12
+ function SliderRow({
13
+ label,
14
+ value,
15
+ display,
16
+ min,
17
+ max,
18
+ step,
19
+ onChange,
20
+ }: {
21
+ label: string
22
+ value: number
23
+ display: string
24
+ min: number
25
+ max: number
26
+ step: number
27
+ onChange: (v: number) => void
28
+ }) {
29
+ return (
30
+ <div className="space-y-2">
31
+ <div className="flex items-baseline justify-between">
32
+ <Label>{label}</Label>
33
+ <span className="font-mono text-xs text-foreground tabular-nums">{display}</span>
34
+ </div>
35
+ <Slider
36
+ value={[value]}
37
+ min={min}
38
+ max={max}
39
+ step={step}
40
+ onValueChange={([v]) => onChange(v)}
41
+ />
42
+ </div>
43
+ )
44
+ }
45
+
46
+ export function DetectorPanel() {
47
+ const { params, setParam, videoFile, setVideo, run, running } = useDashboard()
48
+ const fileRef = useRef<HTMLInputElement>(null)
49
+ const [dragging, setDragging] = useState(false)
50
+ const [advanced, setAdvanced] = useState(false)
51
+
52
+ return (
53
+ <div className="space-y-5">
54
+ {/* dropzone */}
55
+ <div
56
+ onClick={() => fileRef.current?.click()}
57
+ onDragOver={(e) => {
58
+ e.preventDefault()
59
+ setDragging(true)
60
+ }}
61
+ onDragLeave={() => setDragging(false)}
62
+ onDrop={(e) => {
63
+ e.preventDefault()
64
+ setDragging(false)
65
+ const f = e.dataTransfer.files?.[0]
66
+ if (f) setVideo(f)
67
+ }}
68
+ className={cn(
69
+ "group relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center transition-colors",
70
+ dragging
71
+ ? "border-primary/60 bg-primary/5"
72
+ : "border-border hover:border-white/20 hover:bg-white/[0.02]",
73
+ )}
74
+ >
75
+ <input
76
+ ref={fileRef}
77
+ type="file"
78
+ accept="video/*"
79
+ className="hidden"
80
+ onChange={(e) => e.target.files?.[0] && setVideo(e.target.files[0])}
81
+ />
82
+ <div className="flex size-9 items-center justify-center rounded-full border border-border bg-white/5 text-muted-foreground group-hover:text-foreground">
83
+ <Upload className="size-4" />
84
+ </div>
85
+ {videoFile ? (
86
+ <div className="min-w-0">
87
+ <p className="truncate font-mono text-xs text-foreground">{videoFile.name}</p>
88
+ <p className="text-[0.6875rem] text-muted-foreground">
89
+ {(videoFile.size / 1e6).toFixed(1)} MB · click to replace
90
+ </p>
91
+ </div>
92
+ ) : (
93
+ <div>
94
+ <p className="text-sm text-foreground">Drop a clip or click to upload</p>
95
+ <p className="text-[0.6875rem] text-muted-foreground">mp4 · webm · mov</p>
96
+ </div>
97
+ )}
98
+ </div>
99
+
100
+ <div className="space-y-2">
101
+ <Label htmlFor="classes">Open-vocabulary classes</Label>
102
+ <Textarea
103
+ id="classes"
104
+ value={params.classes}
105
+ onChange={(e) => setParam("classes", e.target.value)}
106
+ className="min-h-16 font-mono text-xs leading-relaxed"
107
+ placeholder="person, steering wheel, monitor…"
108
+ />
109
+ </div>
110
+
111
+ <div className="space-y-2">
112
+ <Label htmlFor="model">Detector model</Label>
113
+ <Input
114
+ id="model"
115
+ value={params.modelName}
116
+ onChange={(e) => setParam("modelName", e.target.value)}
117
+ className="font-mono text-xs"
118
+ />
119
+ </div>
120
+
121
+ <div className="space-y-4">
122
+ <SliderRow
123
+ label="Confidence"
124
+ value={params.confidence}
125
+ display={params.confidence.toFixed(2)}
126
+ min={0.05}
127
+ max={0.9}
128
+ step={0.05}
129
+ onChange={(v) => setParam("confidence", Number(v.toFixed(2)))}
130
+ />
131
+ <SliderRow
132
+ label="Frame stride"
133
+ value={params.frameStride}
134
+ display={`${params.frameStride}`}
135
+ min={1}
136
+ max={30}
137
+ step={1}
138
+ onChange={(v) => setParam("frameStride", v)}
139
+ />
140
+ <SliderRow
141
+ label="Max frames"
142
+ value={params.maxFrames}
143
+ display={`${params.maxFrames}`}
144
+ min={10}
145
+ max={300}
146
+ step={10}
147
+ onChange={(v) => setParam("maxFrames", v)}
148
+ />
149
+ </div>
150
+
151
+ <div>
152
+ <button
153
+ onClick={() => setAdvanced((v) => !v)}
154
+ className="flex w-full items-center justify-between text-xs font-medium text-muted-foreground hover:text-foreground"
155
+ >
156
+ Advanced
157
+ <ChevronDown className={cn("size-3.5 transition-transform", advanced && "rotate-180")} />
158
+ </button>
159
+ {advanced && (
160
+ <div className="mt-3 space-y-3 reveal">
161
+ <div className="grid grid-cols-2 gap-3">
162
+ <div className="space-y-1.5">
163
+ <Label htmlFor="device">Device</Label>
164
+ <Input
165
+ id="device"
166
+ value={params.device}
167
+ onChange={(e) => setParam("device", e.target.value)}
168
+ className="font-mono text-xs"
169
+ placeholder="auto / cuda:0 / cpu"
170
+ />
171
+ </div>
172
+ <div className="space-y-1.5">
173
+ <Label htmlFor="imgsz">Image size</Label>
174
+ <Input
175
+ id="imgsz"
176
+ type="number"
177
+ value={params.imageSize || ""}
178
+ onChange={(e) => setParam("imageSize", Number(e.target.value) || 0)}
179
+ className="font-mono text-xs"
180
+ placeholder="auto"
181
+ />
182
+ </div>
183
+ </div>
184
+ <div className="space-y-1.5">
185
+ <Label htmlFor="maxdet">Max detections</Label>
186
+ <Input
187
+ id="maxdet"
188
+ type="number"
189
+ value={params.maxDetections || ""}
190
+ onChange={(e) => setParam("maxDetections", Number(e.target.value) || 0)}
191
+ className="font-mono text-xs"
192
+ placeholder="default"
193
+ />
194
+ </div>
195
+ </div>
196
+ )}
197
+ </div>
198
+
199
+ <Separator />
200
+
201
+ <div className="flex gap-2">
202
+ <Button
203
+ variant="secondary"
204
+ size="default"
205
+ className="flex-1"
206
+ onClick={() => {
207
+ setVideo(null)
208
+ }}
209
+ disabled={running}
210
+ >
211
+ <RotateCcw className="size-4" />
212
+ Reset
213
+ </Button>
214
+ <Button className="flex-[2]" onClick={run} disabled={running}>
215
+ {running ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
216
+ {running ? "Detecting…" : "Run detection"}
217
+ </Button>
218
+ </div>
219
+ </div>
220
+ )
221
+ }
frontend/src/modules/preview/PreviewPanel.tsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Activity, Film, Loader2, Zap } from "lucide-react"
2
+ import { useDashboard } from "@/lib/dashboard"
3
+ import { mediaUrl } from "@/lib/api"
4
+ import { Badge } from "@/components/ui/badge"
5
+ import { cn } from "@/lib/utils"
6
+
7
+ function Stat({
8
+ label,
9
+ value,
10
+ accent,
11
+ }: {
12
+ label: string
13
+ value: string
14
+ accent?: boolean
15
+ }) {
16
+ return (
17
+ <div className="rounded-lg border border-border bg-black/20 px-4 py-3">
18
+ <div className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground">
19
+ {label}
20
+ </div>
21
+ <div
22
+ className={cn(
23
+ "mt-1 font-display text-2xl font-semibold tabular-nums",
24
+ accent ? "text-primary" : "text-foreground",
25
+ )}
26
+ >
27
+ {value}
28
+ </div>
29
+ </div>
30
+ )
31
+ }
32
+
33
+ export function PreviewPanel() {
34
+ const { result, running, videoPreviewUrl } = useDashboard()
35
+ const fired = result?.status === "fired"
36
+ const src = result ? mediaUrl(result.video_url) : videoPreviewUrl
37
+
38
+ return (
39
+ <div className="space-y-4">
40
+ <div className="group relative aspect-video overflow-hidden rounded-lg border border-border bg-black">
41
+ {/* scanline / grid atmosphere */}
42
+ <div
43
+ className="pointer-events-none absolute inset-0 opacity-[0.07]"
44
+ style={{
45
+ backgroundImage:
46
+ "linear-gradient(oklch(1 0 0 / 0.5) 1px, transparent 1px), linear-gradient(90deg, oklch(1 0 0 / 0.5) 1px, transparent 1px)",
47
+ backgroundSize: "32px 32px",
48
+ }}
49
+ />
50
+
51
+ {src ? (
52
+ <video
53
+ key={src}
54
+ src={src}
55
+ controls
56
+ autoPlay
57
+ loop
58
+ muted
59
+ playsInline
60
+ className="absolute inset-0 size-full object-contain"
61
+ />
62
+ ) : (
63
+ <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
64
+ <Film className="size-8 opacity-40" />
65
+ <p className="text-sm">Upload a clip and run detection to see the annotated replay</p>
66
+ </div>
67
+ )}
68
+
69
+ {/* top HUD */}
70
+ <div className="pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between p-3">
71
+ <div className="flex gap-2">
72
+ {result?.classes?.slice(0, 4).map((c) => (
73
+ <span
74
+ key={c}
75
+ className="rounded-md border border-white/10 bg-black/50 px-2 py-1 font-mono text-[0.625rem] uppercase tracking-wider text-white/70 backdrop-blur-sm"
76
+ >
77
+ {c}
78
+ </span>
79
+ ))}
80
+ </div>
81
+ {result &&
82
+ (fired ? (
83
+ <Badge variant="live">
84
+ <span className="size-1.5 rounded-full bg-primary signal-dot" />
85
+ automation fired
86
+ </Badge>
87
+ ) : (
88
+ <Badge variant="warning">no match</Badge>
89
+ ))}
90
+ </div>
91
+
92
+ {/* running overlay */}
93
+ {running && (
94
+ <div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm">
95
+ <div className="flex flex-col items-center gap-3 text-primary">
96
+ <Loader2 className="size-7 animate-spin" />
97
+ <span className="font-mono text-xs uppercase tracking-widest">running inference</span>
98
+ </div>
99
+ </div>
100
+ )}
101
+
102
+ {/* fired banner */}
103
+ {fired && result && (
104
+ <div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-gradient-to-t from-primary/90 to-primary/70 px-4 py-2.5 text-primary-foreground">
105
+ <div className="flex items-center gap-2 text-sm font-semibold">
106
+ <Zap className="size-4" />
107
+ {result.events[0]?.rule} → {result.events[0]?.action}
108
+ </div>
109
+ <span className="font-mono text-xs tabular-nums">
110
+ {result.events[0]?.timestamp_sec.toFixed(1)}s
111
+ </span>
112
+ </div>
113
+ )}
114
+ </div>
115
+
116
+ {/* stat strip */}
117
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
118
+ <Stat label="Detections" value={result ? `${result.stats.detections}` : "—"} />
119
+ <Stat label="Rules" value={result ? `${result.stats.rules}` : "—"} />
120
+ <Stat label="Actions" value={result ? `${result.stats.actions}` : "—"} accent={fired} />
121
+ <Stat
122
+ label="Frames"
123
+ value={result ? `${result.stats.processed_frames}` : "—"}
124
+ />
125
+ </div>
126
+
127
+ {!result && (
128
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
129
+ <Activity className="size-3.5" />
130
+ Engine idle — awaiting first run.
131
+ </div>
132
+ )}
133
+ </div>
134
+ )
135
+ }
frontend/src/modules/rules/RuleStudioPanel.tsx ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react"
2
+ import {
3
+ CheckCircle2,
4
+ CircleSlash,
5
+ Loader2,
6
+ Save,
7
+ ShieldCheck,
8
+ Sparkles,
9
+ Wand2,
10
+ } from "lucide-react"
11
+ import { useDashboard } from "@/lib/dashboard"
12
+ import { Button } from "@/components/ui/button"
13
+ import { Textarea } from "@/components/ui/textarea"
14
+ import { Badge } from "@/components/ui/badge"
15
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
16
+ import { cn } from "@/lib/utils"
17
+
18
+ export function RuleStudioPanel() {
19
+ const {
20
+ rulesText,
21
+ setRulesText,
22
+ validate,
23
+ validation,
24
+ save,
25
+ compile,
26
+ compiling,
27
+ } = useDashboard()
28
+ const [instruction, setInstruction] = useState(
29
+ "If a person is near the steering wheel, turn on the PC. Don't repeat for five minutes.",
30
+ )
31
+
32
+ return (
33
+ <Tabs defaultValue="compose">
34
+ <div className="flex items-center justify-between">
35
+ <TabsList>
36
+ <TabsTrigger value="compose">
37
+ <Sparkles className="size-3.5" />
38
+ Compose
39
+ </TabsTrigger>
40
+ <TabsTrigger value="source">Source</TabsTrigger>
41
+ <TabsTrigger value="rules">
42
+ Rules
43
+ {validation?.ok && (
44
+ <span className="ml-1 font-mono text-[0.625rem] text-muted-foreground">
45
+ {validation.rules.length}
46
+ </span>
47
+ )}
48
+ </TabsTrigger>
49
+ </TabsList>
50
+ {validation &&
51
+ (validation.ok ? (
52
+ <Badge variant="live">
53
+ <CheckCircle2 className="size-3" />
54
+ valid
55
+ </Badge>
56
+ ) : (
57
+ <Badge variant="danger">
58
+ <CircleSlash className="size-3" />
59
+ invalid
60
+ </Badge>
61
+ ))}
62
+ </div>
63
+
64
+ {/* Compose: NL → rules via llama.cpp */}
65
+ <TabsContent value="compose">
66
+ <div className="space-y-3">
67
+ <p className="text-xs text-muted-foreground">
68
+ Describe the automation in plain language. It compiles to validated rules through
69
+ llama.cpp — never executable code.
70
+ </p>
71
+ <Textarea
72
+ value={instruction}
73
+ onChange={(e) => setInstruction(e.target.value)}
74
+ className="min-h-24"
75
+ placeholder="When a cat is near the feeder, dispense food once every 30 minutes…"
76
+ />
77
+ <Button onClick={() => compile(instruction)} disabled={compiling || !instruction.trim()}>
78
+ {compiling ? <Loader2 className="size-4 animate-spin" /> : <Wand2 className="size-4" />}
79
+ {compiling ? "Compiling…" : "Compile to rules"}
80
+ </Button>
81
+ </div>
82
+ </TabsContent>
83
+
84
+ {/* Source: raw editor */}
85
+ <TabsContent value="source">
86
+ <div className="space-y-3">
87
+ <div className="relative">
88
+ <div className="absolute left-0 top-0 z-10 rounded-br-md rounded-tl-md border-b border-r border-border bg-black/40 px-2 py-0.5 font-mono text-[0.625rem] uppercase tracking-wider text-muted-foreground">
89
+ yaml · json
90
+ </div>
91
+ <Textarea
92
+ value={rulesText}
93
+ onChange={(e) => setRulesText(e.target.value)}
94
+ spellCheck={false}
95
+ className="min-h-64 bg-black/40 pt-7 font-mono text-xs leading-relaxed"
96
+ />
97
+ </div>
98
+ {validation && !validation.ok && validation.error && (
99
+ <pre className="max-h-28 overflow-auto rounded-md border border-destructive/30 bg-destructive/5 p-3 font-mono text-[0.6875rem] text-destructive/90 whitespace-pre-wrap">
100
+ {validation.error}
101
+ </pre>
102
+ )}
103
+ <div className="flex gap-2">
104
+ <Button variant="secondary" onClick={validate}>
105
+ <ShieldCheck className="size-4" />
106
+ Validate
107
+ </Button>
108
+ <Button variant="secondary" onClick={save}>
109
+ <Save className="size-4" />
110
+ Save
111
+ </Button>
112
+ </div>
113
+ </div>
114
+ </TabsContent>
115
+
116
+ {/* Rules: parsed view */}
117
+ <TabsContent value="rules">
118
+ {validation?.ok && validation.rules.length > 0 ? (
119
+ <div className="space-y-2">
120
+ {validation.rules.map((r) => (
121
+ <div
122
+ key={r.name}
123
+ className="flex items-center justify-between rounded-lg border border-border bg-black/20 px-4 py-3"
124
+ >
125
+ <div className="min-w-0">
126
+ <div className="flex items-center gap-2">
127
+ <span
128
+ className={cn(
129
+ "size-1.5 rounded-full",
130
+ r.enabled ? "bg-primary" : "bg-muted-foreground/40",
131
+ )}
132
+ />
133
+ <span className="truncate font-mono text-sm text-foreground">{r.name}</span>
134
+ </div>
135
+ <div className="mt-1 pl-3.5 text-xs text-muted-foreground">
136
+ {r.conditions} condition{r.conditions === 1 ? "" : "s"} →{" "}
137
+ {r.actions.map((a) => `${a.type}:${a.name}`).join(", ")}
138
+ </div>
139
+ </div>
140
+ <Badge variant={r.enabled ? "live" : "default"}>
141
+ {r.enabled ? "enabled" : "disabled"}
142
+ </Badge>
143
+ </div>
144
+ ))}
145
+ </div>
146
+ ) : (
147
+ <div className="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground">
148
+ <ShieldCheck className="size-7 opacity-40" />
149
+ <p className="text-sm">Validate the source to see parsed rules.</p>
150
+ <Button variant="secondary" size="sm" onClick={validate}>
151
+ Validate now
152
+ </Button>
153
+ </div>
154
+ )}
155
+ </TabsContent>
156
+ </Tabs>
157
+ )
158
+ }
frontend/src/modules/settings/SettingsPanel.tsx ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AlertTriangle } from "lucide-react"
2
+ import { useDashboard } from "@/lib/dashboard"
3
+ import { Label } from "@/components/ui/label"
4
+ import { Input } from "@/components/ui/input"
5
+ import { Switch } from "@/components/ui/switch"
6
+ import { Separator } from "@/components/ui/separator"
7
+ import { cn } from "@/lib/utils"
8
+
9
+ export function SettingsPanel() {
10
+ const { params, setParam } = useDashboard()
11
+
12
+ return (
13
+ <div className="space-y-5">
14
+ {/* webhook dispatch */}
15
+ <div className="space-y-3">
16
+ <div className="flex items-center justify-between">
17
+ <div>
18
+ <Label className="text-foreground">Webhook dispatch</Label>
19
+ <p className="mt-0.5 text-[0.6875rem] text-muted-foreground">
20
+ Off by default — actions only simulate.
21
+ </p>
22
+ </div>
23
+ <Switch
24
+ checked={params.enableWebhooks}
25
+ onCheckedChange={(v) => setParam("enableWebhooks", v)}
26
+ />
27
+ </div>
28
+
29
+ <div
30
+ className={cn(
31
+ "transition-opacity",
32
+ params.enableWebhooks ? "opacity-100" : "pointer-events-none opacity-40",
33
+ )}
34
+ >
35
+ <Label htmlFor="webhook">Webhook URL</Label>
36
+ <Input
37
+ id="webhook"
38
+ value={params.webhookUrl}
39
+ onChange={(e) => setParam("webhookUrl", e.target.value)}
40
+ className="mt-1.5 font-mono text-xs"
41
+ placeholder="http://127.0.0.1:8123/api/webhook/example"
42
+ />
43
+ </div>
44
+
45
+ {params.enableWebhooks && (
46
+ <div className="flex items-start gap-2 rounded-md border border-warning/30 bg-warning/5 px-3 py-2 text-[0.6875rem] text-warning reveal">
47
+ <AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
48
+ Live POSTs are enabled. Matching rules will hit the URL above for real.
49
+ </div>
50
+ )}
51
+ </div>
52
+
53
+ <Separator />
54
+
55
+ <div className="space-y-1.5">
56
+ <div className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground">
57
+ rule compiler
58
+ </div>
59
+ <p className="text-xs text-muted-foreground">
60
+ llama.cpp endpoint:{" "}
61
+ <span className="font-mono text-foreground">127.0.0.1:8080/v1</span>
62
+ </p>
63
+ <p className="text-[0.6875rem] text-muted-foreground">
64
+ Start with{" "}
65
+ <span className="font-mono">llama-server -hf ggml-org/Qwen3-1.7B-GGUF:Q4_K_M</span>
66
+ </p>
67
+ </div>
68
+ </div>
69
+ )
70
+ }
frontend/src/styles/index.css ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+
4
+ @custom-variant dark (&:is(.dark *));
5
+
6
+ /* ───────────────────────────────────────────────────────────────
7
+ Tiny Trigger — "Dark Ops Console" design system
8
+ Neutral cool-black surfaces · one electric live-signal accent ·
9
+ distinctive type (Clash Display / Hanken Grotesk / JetBrains Mono)
10
+ ─────────────────────────────────────────────────────────────── */
11
+
12
+ :root {
13
+ --radius: 0.625rem;
14
+
15
+ /* surfaces — cool near-black, layered by elevation */
16
+ --background: oklch(0.145 0.006 264);
17
+ --foreground: oklch(0.93 0.004 264);
18
+ --card: oklch(0.185 0.007 264);
19
+ --card-foreground: oklch(0.93 0.004 264);
20
+ --popover: oklch(0.205 0.008 264);
21
+ --popover-foreground: oklch(0.93 0.004 264);
22
+
23
+ /* the single confident accent — electric "live signal" chartreuse */
24
+ --primary: oklch(0.885 0.19 122);
25
+ --primary-foreground: oklch(0.18 0.03 130);
26
+
27
+ --secondary: oklch(0.235 0.008 264);
28
+ --secondary-foreground: oklch(0.93 0.004 264);
29
+ --muted: oklch(0.235 0.008 264);
30
+ --muted-foreground: oklch(0.64 0.012 264);
31
+ --accent: oklch(0.235 0.008 264);
32
+ --accent-foreground: oklch(0.93 0.004 264);
33
+
34
+ /* semantic */
35
+ --destructive: oklch(0.637 0.21 25.3);
36
+ --destructive-foreground: oklch(0.97 0.01 20);
37
+ --warning: oklch(0.79 0.15 78);
38
+ --warning-foreground: oklch(0.2 0.03 78);
39
+ --info: oklch(0.78 0.12 222);
40
+ --info-foreground: oklch(0.18 0.03 240);
41
+
42
+ --border: oklch(1 0 0 / 8%);
43
+ --input: oklch(1 0 0 / 12%);
44
+ --ring: oklch(0.885 0.19 122 / 55%);
45
+
46
+ /* fonts */
47
+ --font-display: "Clash Display", ui-sans-serif, system-ui, sans-serif;
48
+ --font-sans: "Hanken Grotesk", ui-sans-serif, system-ui, sans-serif;
49
+ --font-mono: "JetBrains Mono", ui-monospace, monospace;
50
+ }
51
+
52
+ @theme inline {
53
+ --color-background: var(--background);
54
+ --color-foreground: var(--foreground);
55
+ --color-card: var(--card);
56
+ --color-card-foreground: var(--card-foreground);
57
+ --color-popover: var(--popover);
58
+ --color-popover-foreground: var(--popover-foreground);
59
+ --color-primary: var(--primary);
60
+ --color-primary-foreground: var(--primary-foreground);
61
+ --color-secondary: var(--secondary);
62
+ --color-secondary-foreground: var(--secondary-foreground);
63
+ --color-muted: var(--muted);
64
+ --color-muted-foreground: var(--muted-foreground);
65
+ --color-accent: var(--accent);
66
+ --color-accent-foreground: var(--accent-foreground);
67
+ --color-destructive: var(--destructive);
68
+ --color-destructive-foreground: var(--destructive-foreground);
69
+ --color-warning: var(--warning);
70
+ --color-warning-foreground: var(--warning-foreground);
71
+ --color-info: var(--info);
72
+ --color-info-foreground: var(--info-foreground);
73
+ --color-border: var(--border);
74
+ --color-input: var(--input);
75
+ --color-ring: var(--ring);
76
+
77
+ --font-display: var(--font-display);
78
+ --font-sans: var(--font-sans);
79
+ --font-mono: var(--font-mono);
80
+
81
+ --radius-sm: calc(var(--radius) - 4px);
82
+ --radius-md: calc(var(--radius) - 2px);
83
+ --radius-lg: var(--radius);
84
+ --radius-xl: calc(var(--radius) + 4px);
85
+ }
86
+
87
+ @layer base {
88
+ * {
89
+ border-color: var(--color-border);
90
+ }
91
+
92
+ html {
93
+ -webkit-font-smoothing: antialiased;
94
+ text-rendering: optimizeLegibility;
95
+ }
96
+
97
+ body {
98
+ margin: 0;
99
+ min-height: 100vh;
100
+ background-color: var(--color-background);
101
+ color: var(--color-foreground);
102
+ font-family: var(--font-sans);
103
+ font-feature-settings: "ss01", "cv01", "tnum";
104
+ /* atmospheric depth: dual radial glows + faint engineering grid */
105
+ background-image:
106
+ radial-gradient(
107
+ 90rem 60rem at 82% -10%,
108
+ oklch(0.885 0.19 122 / 0.07),
109
+ transparent 60%
110
+ ),
111
+ radial-gradient(
112
+ 70rem 50rem at -10% 110%,
113
+ oklch(0.78 0.12 222 / 0.06),
114
+ transparent 55%
115
+ ),
116
+ linear-gradient(
117
+ to right,
118
+ oklch(1 0 0 / 0.018) 1px,
119
+ transparent 1px
120
+ ),
121
+ linear-gradient(
122
+ to bottom,
123
+ oklch(1 0 0 / 0.018) 1px,
124
+ transparent 1px
125
+ );
126
+ background-size: 100% 100%, 100% 100%, 56px 56px, 56px 56px;
127
+ background-attachment: fixed;
128
+ }
129
+
130
+ ::selection {
131
+ background: oklch(0.885 0.19 122 / 0.28);
132
+ }
133
+
134
+ /* refined dark scrollbars */
135
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
136
+ ::-webkit-scrollbar-thumb {
137
+ background: oklch(1 0 0 / 0.1);
138
+ border-radius: 999px;
139
+ border: 2px solid transparent;
140
+ background-clip: content-box;
141
+ }
142
+ ::-webkit-scrollbar-thumb:hover { background: oklch(1 0 0 / 0.18); background-clip: content-box; }
143
+ }
144
+
145
+ @layer utilities {
146
+ .font-display { font-family: var(--font-display); }
147
+ .text-balance { text-wrap: balance; }
148
+
149
+ /* the live-signal pulse used for "fired / active" states */
150
+ .signal-dot {
151
+ box-shadow: 0 0 0 0 var(--color-primary);
152
+ animation: signal-pulse 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
153
+ }
154
+ @keyframes signal-pulse {
155
+ 0% { box-shadow: 0 0 0 0 oklch(0.885 0.19 122 / 0.5); }
156
+ 70% { box-shadow: 0 0 0 8px oklch(0.885 0.19 122 / 0); }
157
+ 100% { box-shadow: 0 0 0 0 oklch(0.885 0.19 122 / 0); }
158
+ }
159
+
160
+ /* staggered entrance for panels */
161
+ .reveal {
162
+ opacity: 0;
163
+ animation: reveal-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) forwards;
164
+ }
165
+ @keyframes reveal-up {
166
+ from { opacity: 0; transform: translateY(12px); }
167
+ to { opacity: 1; transform: translateY(0); }
168
+ }
169
+
170
+ .hairline {
171
+ background-image: linear-gradient(to right, transparent, var(--color-border), transparent);
172
+ }
173
+ }
frontend/tsconfig.app.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "verbatimModuleSyntax": true,
11
+ "moduleDetection": "force",
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "baseUrl": ".",
19
+ "paths": { "@/*": ["./src/*"] }
20
+ },
21
+ "include": ["src"]
22
+ }
frontend/tsconfig.app.tsbuildinfo ADDED
@@ -0,0 +1 @@
 
 
1
+ {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/layout/modulepanel.tsx","./src/components/layout/section.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/separator.tsx","./src/components/ui/slider.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api.ts","./src/lib/dashboard.tsx","./src/lib/types.ts","./src/lib/utils.ts","./src/modules/registry.ts","./src/modules/activity/activitypanel.tsx","./src/modules/detector/detectorpanel.tsx","./src/modules/preview/previewpanel.tsx","./src/modules/rules/rulestudiopanel.tsx","./src/modules/settings/settingspanel.tsx"],"version":"5.9.3"}
frontend/tsconfig.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ],
7
+ "compilerOptions": {
8
+ "baseUrl": ".",
9
+ "paths": { "@/*": ["./src/*"] }
10
+ }
11
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2023"],
5
+ "module": "ESNext",
6
+ "skipLibCheck": true,
7
+ "moduleResolution": "bundler",
8
+ "allowImportingTsExtensions": true,
9
+ "verbatimModuleSyntax": true,
10
+ "moduleDetection": "force",
11
+ "noEmit": true,
12
+ "strict": true,
13
+ "noUnusedLocals": true,
14
+ "noUnusedParameters": true,
15
+ "types": ["node"]
16
+ },
17
+ "include": ["vite.config.ts"]
18
+ }
frontend/tsconfig.node.tsbuildinfo ADDED
@@ -0,0 +1 @@
 
 
1
+ {"root":["./vite.config.ts"],"version":"5.9.3"}
poetry.lock ADDED
The diff for this file is too large to render. See raw diff
 
pyproject.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "tiny-trigger"
3
+ version = "0.1.0"
4
+ description = "Open-vocabulary video automations with YOLOE and llama.cpp"
5
+ authors = [
6
+ {name = "Javier Montalvo",email = "jmrgua@gmail.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10,!=3.14.1,<3.15"
10
+ dependencies = [
11
+ "gradio>=5.0",
12
+ "ultralytics>=8.3.0",
13
+ "opencv-python-headless>=4.10",
14
+ "pydantic>=2.8",
15
+ "PyYAML>=6.0",
16
+ "requests>=2.32",
17
+ "numpy>=1.26",
18
+ "imageio-ffmpeg>=0.6.0",
19
+ "static-ffmpeg>=2.13",
20
+ "torch (>=2.11.0,<3.0.0)",
21
+ "torchvision (>=0.26.0,<0.27.0)",
22
+ "torchaudio (>=2.11.0,<3.0.0)",
23
+ "llama-cpp-python[server] (>=0.3.28,<0.4.0)",
24
+ ]
25
+
26
+ [tool.poetry.group.dev.dependencies]
27
+ pytest = ">=9.0"
28
+
29
+ [build-system]
30
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
31
+ build-backend = "poetry.core.masonry.api"
pytest.ini ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [pytest]
2
+ testpaths = tests
3
+ pythonpath = .
requirements-dev.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ -r requirements.txt
2
+ pytest>=9.0
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio>=5.0
2
+ ultralytics>=8.3.0
3
+ opencv-python-headless>=4.10
4
+ pydantic>=2.8
5
+ PyYAML>=6.0
6
+ requests>=2.32
7
+ numpy>=1.26
server.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """gradio.Server backend for the custom Tiny Trigger dashboard.
2
+
3
+ Runs independently of ``app.py`` (the legacy Gradio Blocks UI). It serves the
4
+ built React frontend (``frontend/dist``) and exposes the Tiny Trigger engine as
5
+ ``@app.api`` endpoints that keep Gradio's queuing / SSE / gradio_client
6
+ compatibility. The frontend talks to these via the ``@gradio/client`` JS library.
7
+
8
+ poetry run python server.py # serves the dashboard on :7860
9
+
10
+ The heavy ML imports stay lazy (inside ``tiny_trigger``); importing this module
11
+ does not require torch/ultralytics.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from gradio import FileData, Server
20
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
21
+
22
+ from tiny_trigger import (
23
+ compile_automation_with_llamacpp,
24
+ evaluate_video_detections,
25
+ load_automation_text,
26
+ parse_class_prompt,
27
+ process_video,
28
+ )
29
+ from tiny_trigger.actions import dispatch_events
30
+ from tiny_trigger.store import (
31
+ load_local_config,
32
+ load_runtime_state,
33
+ load_saved_automations,
34
+ save_automations,
35
+ save_runtime_state,
36
+ append_events,
37
+ )
38
+ from tiny_trigger.video import render_automation_video
39
+
40
+ # ── paths ──────────────────────────────────────────────────────────────────
41
+ ROOT = Path(__file__).parent
42
+ DIST = ROOT / "frontend" / "dist"
43
+ ASSETS = DIST / "assets"
44
+ RENDERS = ROOT / ".local" / "renders"
45
+ RENDERS.mkdir(parents=True, exist_ok=True)
46
+
47
+ DEFAULT_MODEL = "yoloe-26s-seg.pt"
48
+
49
+
50
+ # ── serialization helpers (JSON-shaped, for the custom frontend) ─────────────
51
+ def _detection_dict(d: Any) -> dict[str, Any]:
52
+ return {
53
+ "frame_index": d.frame_index,
54
+ "timestamp_sec": round(d.timestamp_sec, 3),
55
+ "label": d.label,
56
+ "confidence": round(d.confidence, 4),
57
+ "bbox_xyxy": [round(v, 1) for v in d.bbox_xyxy],
58
+ "bbox_xyxy_norm": [round(v, 4) for v in d.bbox_xyxy_norm],
59
+ }
60
+
61
+
62
+ def _event_dict(e: Any) -> dict[str, Any]:
63
+ return {
64
+ "rule": e.rule,
65
+ "action": e.action,
66
+ "type": e.type,
67
+ "frame_index": e.frame_index,
68
+ "timestamp_sec": round(e.timestamp_sec, 3),
69
+ "status": e.status,
70
+ "url": e.url,
71
+ "response_status": e.response_status,
72
+ "error": e.error,
73
+ }
74
+
75
+
76
+ def _media_url(path_str: str) -> str:
77
+ """Map a rendered file path under RENDERS to a served /media URL."""
78
+ return f"/media/{Path(path_str).name}"
79
+
80
+
81
+ def _file_path(video: Any) -> str | None:
82
+ """FileData arrives as a dict over the wire; tolerate the object form too."""
83
+ if video is None:
84
+ return None
85
+ if isinstance(video, dict):
86
+ return video.get("path")
87
+ return getattr(video, "path", None)
88
+
89
+
90
+ # ── the server ───────────────────────────────────────────────────────────────
91
+ app = Server(title="Tiny Trigger", description="Open-vocabulary video automations")
92
+
93
+
94
+ @app.api(name="detect_and_automate")
95
+ def detect_and_automate(
96
+ video: FileData,
97
+ classes: str,
98
+ rules_text: str,
99
+ confidence: float = 0.25,
100
+ frame_stride: int = 5,
101
+ max_frames: int = 120,
102
+ model_name: str = DEFAULT_MODEL,
103
+ image_size: int = 0,
104
+ device: str = "auto",
105
+ max_detections: int = 0,
106
+ enable_webhooks: bool = False,
107
+ webhook_url: str = "",
108
+ ) -> dict:
109
+ """Detect once, evaluate rules, dispatch actions, render an overlay clip."""
110
+ video_path = _file_path(video)
111
+ if not video_path:
112
+ raise ValueError("A video file is required.")
113
+
114
+ rules = load_automation_text(rules_text)
115
+ state = load_runtime_state()
116
+
117
+ result = process_video(
118
+ video_path=video_path,
119
+ class_prompt=classes,
120
+ confidence=confidence,
121
+ frame_stride=frame_stride,
122
+ max_frames=max_frames,
123
+ model_name=model_name or DEFAULT_MODEL,
124
+ image_size=image_size or None,
125
+ device=None if device in ("", "auto") else device,
126
+ max_detections=max_detections or None,
127
+ output_dir=str(RENDERS),
128
+ )
129
+
130
+ events, last_fired = evaluate_video_detections(
131
+ rules.rules, result.detections, last_fired=state.last_fired
132
+ )
133
+ dispatched = dispatch_events(
134
+ events, enable_webhooks=enable_webhooks, webhook_url=webhook_url or None
135
+ )
136
+ state.last_fired = last_fired
137
+ save_runtime_state(state)
138
+ append_events(dispatched)
139
+
140
+ automation_path = render_automation_video(
141
+ source_video_path=video_path,
142
+ detections=result.detections,
143
+ events=dispatched,
144
+ frame_stride=frame_stride,
145
+ max_frames=max_frames,
146
+ output_dir=str(RENDERS),
147
+ )
148
+
149
+ fired = len(dispatched)
150
+ return {
151
+ "status": "fired" if fired else "no_match",
152
+ "video_url": _media_url(automation_path),
153
+ "annotated_url": _media_url(result.output_video_path),
154
+ "classes": result.classes,
155
+ "stats": {
156
+ "detections": len(result.detections),
157
+ "rules": len(rules.rules),
158
+ "actions": fired,
159
+ "processed_frames": result.processed_frames,
160
+ "source_fps": round(result.source_fps, 2),
161
+ "output_fps": round(result.output_fps, 2),
162
+ },
163
+ "detections": [_detection_dict(d) for d in result.detections],
164
+ "events": [_event_dict(e) for e in dispatched],
165
+ }
166
+
167
+
168
+ @app.api(name="compile_rules")
169
+ def compile_rules(
170
+ instruction: str,
171
+ classes: str = "",
172
+ base_url: str = "http://127.0.0.1:8080/v1",
173
+ model: str = "ggml-org/Qwen3-1.7B-GGUF:Q4_K_M",
174
+ ) -> dict:
175
+ """Compile a natural-language request into validated automation rules."""
176
+ class_names = parse_class_prompt(classes) if classes else []
177
+ compiled = compile_automation_with_llamacpp(
178
+ instruction=instruction,
179
+ class_names=class_names,
180
+ base_url=base_url,
181
+ model=model,
182
+ )
183
+ return {
184
+ "rules_text": compiled.document.model_dump_json(by_alias=True, indent=2),
185
+ "raw_text": compiled.raw_text,
186
+ "rule_count": len(compiled.document.rules),
187
+ }
188
+
189
+
190
+ @app.api(name="validate_rules")
191
+ def validate_rules(rules_text: str) -> dict:
192
+ """Validate JSON/YAML rules; return a structured summary or the error."""
193
+ try:
194
+ document = load_automation_text(rules_text)
195
+ except Exception as exc: # noqa: BLE001 — surface any validation error to the UI
196
+ return {"ok": False, "error": str(exc), "rules": []}
197
+ return {
198
+ "ok": True,
199
+ "error": None,
200
+ "rules": [
201
+ {
202
+ "name": r.name,
203
+ "enabled": r.gate.enabled,
204
+ "conditions": len(r.when.all_conditions) + len(r.when.any_conditions),
205
+ "actions": [{"type": a.type, "name": a.name} for a in r.then],
206
+ }
207
+ for r in document.rules
208
+ ],
209
+ }
210
+
211
+
212
+ @app.api(name="save_rules")
213
+ def save_rules(rules_text: str) -> dict:
214
+ document = load_automation_text(rules_text)
215
+ save_automations(document)
216
+ return {"ok": True, "rule_count": len(document.rules)}
217
+
218
+
219
+ @app.api(name="load_rules")
220
+ def load_rules() -> dict:
221
+ document = load_saved_automations()
222
+ if document is None:
223
+ return {"rules_text": None}
224
+ return {"rules_text": document.model_dump_json(by_alias=True, indent=2)}
225
+
226
+
227
+ @app.api(name="get_config")
228
+ def get_config() -> dict:
229
+ cfg = load_local_config()
230
+ return cfg.model_dump()
231
+
232
+
233
+ # ── static frontend + media (custom routes take priority over gradio's) ──────
234
+ @app.get("/", response_class=HTMLResponse)
235
+ def index() -> Any:
236
+ index_html = DIST / "index.html"
237
+ if not index_html.exists():
238
+ return HTMLResponse(
239
+ "<h1>Frontend not built</h1><p>Run <code>pnpm --dir frontend build</code>.</p>",
240
+ status_code=503,
241
+ )
242
+ return FileResponse(index_html)
243
+
244
+
245
+ @app.get("/assets/{file_path:path}")
246
+ def assets(file_path: str) -> Any:
247
+ target = (ASSETS / file_path).resolve()
248
+ if not str(target).startswith(str(ASSETS.resolve())) or not target.is_file():
249
+ return JSONResponse({"error": "not found"}, status_code=404)
250
+ return FileResponse(target)
251
+
252
+
253
+ @app.get("/media/{file_path:path}")
254
+ def media(file_path: str) -> Any:
255
+ target = (RENDERS / file_path).resolve()
256
+ if not str(target).startswith(str(RENDERS.resolve())) or not target.is_file():
257
+ return JSONResponse({"error": "not found"}, status_code=404)
258
+ return FileResponse(target, media_type="video/mp4")
259
+
260
+
261
+ if __name__ == "__main__":
262
+ app.launch(server_name="127.0.0.1", server_port=7860, show_error=True)
tests/test_automation.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from pydantic import ValidationError
7
+
8
+ from tiny_trigger.automation import RuleEngine, evaluate_video_detections, load_automation_text
9
+ from tiny_trigger.models import Detection
10
+
11
+
12
+ def detection(label: str, box: tuple[float, float, float, float], frame: int = 0, time: float = 0.0) -> Detection:
13
+ return Detection(
14
+ frame_index=frame,
15
+ timestamp_sec=time,
16
+ label=label,
17
+ confidence=0.9,
18
+ bbox_xyxy=(0.0, 0.0, 10.0, 10.0),
19
+ bbox_xyxy_norm=box,
20
+ )
21
+
22
+
23
+ def test_presence_near_and_cooldown_fire_once_per_window() -> None:
24
+ document = load_automation_text(
25
+ json.dumps(
26
+ {
27
+ "rules": [
28
+ {
29
+ "name": "feed-cat",
30
+ "when": {
31
+ "all": [
32
+ {"present": {"label": "cat", "min_count": 1}},
33
+ {"near": {"a": "cat", "b": "feeder robot", "max_distance": 0.35}},
34
+ {"cooldown": {"key": "feed-cat", "seconds": 60}},
35
+ ]
36
+ },
37
+ "then": [{"type": "simulate", "name": "feed cat"}],
38
+ }
39
+ ]
40
+ }
41
+ )
42
+ )
43
+ engine = RuleEngine(document.rules)
44
+ frame = [
45
+ detection("cat", (0.10, 0.10, 0.20, 0.20), time=0.0),
46
+ detection("feeder robot", (0.25, 0.12, 0.35, 0.22), time=0.0),
47
+ ]
48
+
49
+ assert len(engine.evaluate_frame(frame, frame_index=0, timestamp_sec=0.0)) == 1
50
+ assert engine.evaluate_frame(frame, frame_index=1, timestamp_sec=30.0) == []
51
+ assert len(engine.evaluate_frame(frame, frame_index=2, timestamp_sec=61.0)) == 1
52
+
53
+
54
+ def test_count_condition() -> None:
55
+ document = load_automation_text(
56
+ json.dumps(
57
+ {
58
+ "rules": [
59
+ {
60
+ "name": "two-packages",
61
+ "when": {"all": [{"count": {"label": "package", "min": 2}}]},
62
+ "then": [{"type": "simulate", "name": "notify"}],
63
+ }
64
+ ]
65
+ }
66
+ )
67
+ )
68
+ engine = RuleEngine(document.rules)
69
+ one = [detection("package", (0.1, 0.1, 0.2, 0.2))]
70
+ two = [*one, detection("package", (0.4, 0.4, 0.5, 0.5))]
71
+
72
+ assert engine.evaluate_frame(one, frame_index=0, timestamp_sec=0.0) == []
73
+ assert len(engine.evaluate_frame(two, frame_index=1, timestamp_sec=1.0)) == 1
74
+
75
+
76
+ def test_gate_cooldown_fire_once_per_window() -> None:
77
+ document = load_automation_text(
78
+ json.dumps(
79
+ {
80
+ "rules": [
81
+ {
82
+ "name": "turn-on-pc",
83
+ "when": {
84
+ "all": [
85
+ {"present": {"label": "person"}},
86
+ {"near": {"a": "person", "b": "steering wheel", "max_distance": 0.45}},
87
+ ]
88
+ },
89
+ "gate": {"enabled": True, "cooldown": {"key": "turn-on-pc", "seconds": 60}},
90
+ "then": [{"type": "simulate", "name": "turn on pc"}],
91
+ }
92
+ ]
93
+ }
94
+ )
95
+ )
96
+ engine = RuleEngine(document.rules)
97
+ frame = [
98
+ detection("person", (0.10, 0.10, 0.20, 0.20), time=0.0),
99
+ detection("steering wheel", (0.25, 0.12, 0.35, 0.22), time=0.0),
100
+ ]
101
+
102
+ assert len(engine.evaluate_frame(frame, frame_index=0, timestamp_sec=0.0)) == 1
103
+ assert engine.evaluate_frame(frame, frame_index=1, timestamp_sec=30.0) == []
104
+ assert len(engine.evaluate_frame(frame, frame_index=2, timestamp_sec=61.0)) == 1
105
+
106
+
107
+ def test_disabled_gate_blocks_rule() -> None:
108
+ document = load_automation_text(
109
+ json.dumps(
110
+ {
111
+ "rules": [
112
+ {
113
+ "name": "disabled",
114
+ "when": {"all": [{"present": {"label": "person"}}]},
115
+ "gate": {"enabled": False},
116
+ "then": [{"type": "simulate", "name": "turn on pc"}],
117
+ }
118
+ ]
119
+ }
120
+ )
121
+ )
122
+ engine = RuleEngine(document.rules)
123
+
124
+ assert engine.evaluate_frame([detection("person", (0.1, 0.1, 0.2, 0.2))], frame_index=0, timestamp_sec=0.0) == []
125
+
126
+
127
+ def test_evaluate_video_detections_returns_updated_last_fired() -> None:
128
+ document = load_automation_text(
129
+ json.dumps(
130
+ {
131
+ "rules": [
132
+ {
133
+ "name": "turn-on-pc",
134
+ "when": {"all": [{"present": {"label": "person"}}]},
135
+ "gate": {"cooldown": {"key": "turn-on-pc", "seconds": 60}},
136
+ "then": [{"type": "simulate", "name": "turn on pc"}],
137
+ }
138
+ ]
139
+ }
140
+ )
141
+ )
142
+ events, last_fired = evaluate_video_detections(
143
+ document.rules,
144
+ [detection("person", (0.1, 0.1, 0.2, 0.2), frame=2, time=12.0)],
145
+ )
146
+ blocked_events, unchanged_last_fired = evaluate_video_detections(
147
+ document.rules,
148
+ [detection("person", (0.1, 0.1, 0.2, 0.2), frame=3, time=30.0)],
149
+ last_fired=last_fired,
150
+ )
151
+
152
+ assert len(events) == 1
153
+ assert last_fired == {"turn-on-pc": 12.0}
154
+ assert blocked_events == []
155
+ assert unchanged_last_fired == last_fired
156
+
157
+
158
+ def test_invalid_rule_rejected() -> None:
159
+ with pytest.raises(ValidationError):
160
+ load_automation_text(
161
+ json.dumps(
162
+ {
163
+ "rules": [
164
+ {
165
+ "name": "bad",
166
+ "when": {"all": [{"present": {"label": "cat"}, "near": {"a": "cat", "b": "bowl"}}]},
167
+ "then": [{"type": "simulate", "name": "bad"}],
168
+ }
169
+ ]
170
+ }
171
+ )
172
+ )
tests/test_detector.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from tiny_trigger.detector import parse_class_prompt
4
+
5
+
6
+ def test_parse_class_prompt_splits_and_dedupes() -> None:
7
+ assert parse_class_prompt(" cat, feeder robot\npackage; cat ") == ["cat", "feeder robot", "package"]
tests/test_llm.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ from tiny_trigger.automation import AutomationDocument
7
+ from tiny_trigger.llm import (
8
+ SYSTEM_PROMPT,
9
+ _build_user_prompt,
10
+ _chat_payload,
11
+ compile_automation_with_llamacpp,
12
+ _post_chat_completion,
13
+ _validate_compile_result,
14
+ extract_json_object,
15
+ )
16
+
17
+
18
+ def test_extract_json_object_from_fenced_response() -> None:
19
+ raw = """```json
20
+ {"rules": [{"name": "notify", "when": {"all": [{"present": {"label": "package"}}]}, "then": [{"type": "simulate", "name": "notify"}]}]}
21
+ ```"""
22
+ data = json.loads(extract_json_object(raw))
23
+ document = AutomationDocument.model_validate(data)
24
+
25
+ assert document.rules[0].name == "notify"
26
+
27
+
28
+ def test_llamacpp_payload_uses_json_object() -> None:
29
+ payload = _chat_payload(model="qwen", user_prompt="compile this", response_format="json_object")
30
+
31
+ assert payload["response_format"] == {"type": "json_object"}
32
+
33
+
34
+ def test_prompt_includes_validation_schema() -> None:
35
+ prompt = _build_user_prompt(instruction="if package at door notify me", class_names=["package", "door"])
36
+
37
+ assert "Available detection labels: package, door" in prompt
38
+ assert "Full validation schema" in prompt
39
+ assert "AutomationDocument" in prompt
40
+
41
+
42
+ def test_prompt_teaches_near_relations() -> None:
43
+ prompt = _build_user_prompt(
44
+ instruction="If person near steering wheel then turn on pc.",
45
+ class_names=["person", "steering wheel"],
46
+ )
47
+
48
+ assert '"near": {"a": "person", "b": "steering wheel", "max_distance": 0.45}' in prompt
49
+ assert "Do not replace a near relation with two present conditions." in SYSTEM_PROMPT
50
+ assert "Use state gates in gate: enabled, cooldown." in SYSTEM_PROMPT
51
+ assert '"gate": {"enabled": true}' in prompt
52
+ assert '"gate": {"enabled": true, "cooldown": {"key": "package-at-door", "minutes": 15}}' in prompt
53
+
54
+
55
+ def test_invalid_empty_object_fails_validation() -> None:
56
+ try:
57
+ _validate_compile_result("{}")
58
+ except Exception as exc:
59
+ assert "rules" in str(exc)
60
+ else:
61
+ raise AssertionError("{} should not validate as an automation document")
62
+
63
+
64
+ def test_post_chat_completion_extracts_content() -> None:
65
+ class Response:
66
+ def raise_for_status(self) -> None:
67
+ return None
68
+
69
+ def json(self):
70
+ return {"choices": [{"message": {"content": "{\"rules\": []}"}}]}
71
+
72
+ class Requests:
73
+ def post(self, endpoint, json, timeout):
74
+ assert endpoint == "http://localhost/v1/chat/completions"
75
+ assert json["response_format"] == {"type": "json_object"}
76
+ assert timeout == 1
77
+ return Response()
78
+
79
+ assert (
80
+ _post_chat_completion(
81
+ endpoint="http://localhost/v1/chat/completions",
82
+ model="qwen",
83
+ user_prompt="compile",
84
+ timeout=1,
85
+ requests_module=Requests(),
86
+ )
87
+ == "{\"rules\": []}"
88
+ )
89
+
90
+
91
+ def test_compile_retries_after_invalid_response(monkeypatch) -> None:
92
+ class Response:
93
+ def __init__(self, content: str) -> None:
94
+ self.content = content
95
+
96
+ def raise_for_status(self) -> None:
97
+ return None
98
+
99
+ def json(self):
100
+ return {"choices": [{"message": {"content": self.content}}]}
101
+
102
+ class Requests:
103
+ calls = 0
104
+
105
+ @classmethod
106
+ def post(cls, endpoint, json, timeout):
107
+ cls.calls += 1
108
+ if cls.calls == 1:
109
+ return Response("{}")
110
+ return Response(
111
+ '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},'
112
+ '"then":[{"type":"simulate","name":"notify"}]}]}'
113
+ )
114
+
115
+ monkeypatch.setitem(sys.modules, "requests", Requests)
116
+
117
+ result = compile_automation_with_llamacpp(
118
+ instruction="if person present notify",
119
+ class_names=["person"],
120
+ base_url="http://localhost/v1",
121
+ model="qwen",
122
+ timeout=1,
123
+ )
124
+
125
+ assert Requests.calls == 2
126
+ assert result.document.rules[0].name == "notify"
tests/test_store.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from tiny_trigger.automation import load_automation_text
6
+ from tiny_trigger.models import ActionEvent
7
+ from tiny_trigger.store import (
8
+ RuntimeState,
9
+ append_events,
10
+ load_local_config,
11
+ load_runtime_state,
12
+ load_saved_automations,
13
+ save_automations,
14
+ save_runtime_state,
15
+ )
16
+
17
+
18
+ def test_local_config_yaml(tmp_path) -> None:
19
+ path = tmp_path / "config.yaml"
20
+ path.write_text("default_device: cuda:0\nwebhook_url: http://example.test\n", encoding="utf-8")
21
+
22
+ config = load_local_config(path)
23
+
24
+ assert config.default_device == "cuda:0"
25
+ assert config.webhook_url == "http://example.test"
26
+
27
+
28
+ def test_save_and_load_automations(tmp_path) -> None:
29
+ path = tmp_path / "automations.json"
30
+ document = load_automation_text(
31
+ json.dumps(
32
+ {
33
+ "rules": [
34
+ {
35
+ "name": "notify",
36
+ "when": {"all": [{"present": {"label": "person"}}]},
37
+ "then": [{"type": "simulate", "name": "notify"}],
38
+ }
39
+ ]
40
+ }
41
+ )
42
+ )
43
+
44
+ save_automations(document, path)
45
+ loaded = load_saved_automations(path)
46
+
47
+ assert loaded is not None
48
+ assert loaded.rules[0].name == "notify"
49
+
50
+
51
+ def test_runtime_state_roundtrip(tmp_path) -> None:
52
+ path = tmp_path / "state.json"
53
+ save_runtime_state(RuntimeState(last_fired={"turn-on-pc": 12.0}), path)
54
+
55
+ assert load_runtime_state(path).last_fired == {"turn-on-pc": 12.0}
56
+
57
+
58
+ def test_append_events_jsonl(tmp_path) -> None:
59
+ path = tmp_path / "events.jsonl"
60
+ append_events(
61
+ [
62
+ ActionEvent(
63
+ rule="turn-on-pc",
64
+ action="turn on pc",
65
+ type="simulate",
66
+ frame_index=0,
67
+ timestamp_sec=0.0,
68
+ )
69
+ ],
70
+ path,
71
+ )
72
+
73
+ rows = path.read_text(encoding="utf-8").splitlines()
74
+ assert len(rows) == 1
75
+ assert json.loads(rows[0])["rule"] == "turn-on-pc"