import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react" import type { ReactNode } from "react" import { toast } from "sonner" import * as api from "./api" import type { CloudCompilerConfig, CompileResult, CompilerProvider, DetectParams, RunResult, ValidationResult, } from "./types" const DEFAULT_CLASSES = "person, cat, car, chair, cup" const DETECTOR_MODEL_VALUES = new Set([ "yoloe-26n-seg.pt", "yoloe-26s-seg.pt", "yoloe-26m-seg.pt", "yoloe-26l-seg.pt", "yoloe-26x-seg.pt", ]) const IMAGE_SIZE_VALUES = new Set([320, 640, 960, 1280]) const DEFAULT_RULES = `rules: - name: example-person-enters-lights when: all: - present: {label: person, min_count: 1} trigger: on: enter gate: enabled: true then: - type: simulate name: turn on lights ` const DEFAULT_PARAMS: DetectParams = { classes: DEFAULT_CLASSES, confidence: 0.25, sampleIntervalSec: 0.25, maxFrames: 120, modelName: "yoloe-26s-seg.pt", imageSize: 640, device: "auto", maxDetections: 0, enableWebhooks: false, webhookUrl: "", } const DEFAULT_COMPILER: CloudCompilerConfig = { provider: "anthropic", replicateApiKey: "", openaiApiKey: "", anthropicApiKey: "", openaiModel: "gpt-5.5", anthropicModel: "claude-sonnet-4-6", } interface DashboardState { params: DetectParams setParam: (key: K, value: DetectParams[K]) => void rulesText: string setRulesText: (text: string) => void replaceRulesText: (text: string, persist?: boolean) => Promise videoFile: File | null videoPreviewUrl: string | null setVideo: (file: File | null) => void result: RunResult | null running: boolean error: string | null run: () => Promise validation: ValidationResult | null validate: () => Promise save: () => Promise setRuleEnabled: (ruleName: string, enabled: boolean) => Promise deleteRule: (ruleName: string) => Promise compiler: CloudCompilerConfig setCompilerProvider: (provider: CompilerProvider) => void setCompilerApiKey: (provider: CompilerProvider, apiKey: string) => void setCompilerModel: (provider: "openai" | "anthropic", model: string) => void compile: (instruction: string) => Promise compiling: boolean } const Ctx = createContext(null) export function DashboardProvider({ children }: { children: ReactNode }) { const [params, setParams] = useState(DEFAULT_PARAMS) const [rulesText, setRulesText] = useState(DEFAULT_RULES) const [videoFile, setVideoFile] = useState(null) const [videoPreviewUrl, setVideoPreviewUrl] = useState(null) const [result, setResult] = useState(null) const [running, setRunning] = useState(false) const [error, setError] = useState(null) const [validation, setValidation] = useState(null) const [compiler, setCompiler] = useState(() => ({ ...DEFAULT_COMPILER, provider: (localStorage.getItem("tiny-trigger-compiler-provider") as CompilerProvider | null) ?? DEFAULT_COMPILER.provider, openaiModel: localStorage.getItem("tiny-trigger-openai-model") ?? DEFAULT_COMPILER.openaiModel, anthropicModel: localStorage.getItem("tiny-trigger-anthropic-model") ?? DEFAULT_COMPILER.anthropicModel, })) const [compiling, setCompiling] = useState(false) const previewRef = useRef(null) const compileInFlightRef = useRef(false) const applyRulesText = useCallback(async (nextRulesText: string) => { setRulesText(nextRulesText) const nextValidation = await api.validateRules(nextRulesText) setValidation(nextValidation) return nextValidation }, []) const replaceRulesText = useCallback( async (nextRulesText: string, persist = false) => { const nextValidation = await applyRulesText(nextRulesText) if (persist && nextValidation.ok) { await api.saveRules(nextRulesText) } return nextValidation }, [applyRulesText], ) // Hydrate defaults from backend config + saved rules on mount. useEffect(() => { api .getConfig() .then((cfg) => { setParams((p) => ({ ...p, classes: cfg.default_classes ?? p.classes, modelName: cfg.default_detector_model && DETECTOR_MODEL_VALUES.has(cfg.default_detector_model) ? cfg.default_detector_model : p.modelName, device: cfg.default_device ?? p.device, imageSize: cfg.default_image_size && IMAGE_SIZE_VALUES.has(cfg.default_image_size) ? cfg.default_image_size : p.imageSize, maxDetections: cfg.default_max_detections ?? p.maxDetections, webhookUrl: cfg.webhook_url ?? p.webhookUrl, })) if (cfg.llm_provider === "replicate" || cfg.llm_provider === "openai" || cfg.llm_provider === "anthropic") { setCompilerProvider(cfg.llm_provider) } setCompiler((current) => ({ ...current, openaiModel: cfg.openai_model ?? current.openaiModel, anthropicModel: cfg.anthropic_model ?? current.anthropicModel, })) }) .catch(() => void 0) api .loadRules() .then((r) => { void applyRulesText(r.rules_text || DEFAULT_RULES) }) .catch(() => { void applyRulesText(DEFAULT_RULES) }) }, [applyRulesText]) const setParam = useCallback( (key: K, value: DetectParams[K]) => { setParams((p) => ({ ...p, [key]: value })) }, [], ) const setCompilerProvider = useCallback((provider: CompilerProvider) => { localStorage.setItem("tiny-trigger-compiler-provider", provider) setCompiler((current) => ({ ...current, provider })) }, []) const setCompilerApiKey = useCallback((provider: CompilerProvider, apiKey: string) => { const keyByProvider: Record = { replicate: "replicateApiKey", openai: "openaiApiKey", anthropic: "anthropicApiKey", } setCompiler((current) => ({ ...current, [keyByProvider[provider]]: apiKey })) }, []) const setCompilerModel = useCallback((provider: "openai" | "anthropic", model: string) => { const key = provider === "openai" ? "openaiModel" : "anthropicModel" localStorage.setItem(`tiny-trigger-${provider}-model`, model) setCompiler((current) => ({ ...current, [key]: model })) }, []) const setVideo = useCallback((file: File | null) => { if (previewRef.current) URL.revokeObjectURL(previewRef.current) if (file) { const url = URL.createObjectURL(file) previewRef.current = url setVideoPreviewUrl(url) } else { previewRef.current = null setVideoPreviewUrl(null) } setVideoFile(file) }, []) const run = useCallback(async () => { if (!videoFile) { toast.error("Upload a video first") return } setRunning(true) setError(null) try { const res = await api.detectAndAutomate(videoFile, rulesText, params) setResult(res) if (res.status === "fired") { toast.success(`${res.stats.actions} action${res.stats.actions === 1 ? "" : "s"} fired`) } else { toast.message("No frame matched the automation", { description: `${res.stats.detections} detections across ${res.stats.processed_frames} frames`, }) } } catch (e) { const message = e instanceof Error ? e.message : String(e) setError(message) toast.error("Run failed", { description: message }) } finally { setRunning(false) } }, [videoFile, rulesText, params]) const validate = useCallback(async () => { try { const v = await applyRulesText(rulesText) if (v.ok) toast.success(`Validated — ${v.rules.length} rule${v.rules.length === 1 ? "" : "s"}`) else toast.error("Validation failed") } catch (e) { toast.error("Validation error", { description: e instanceof Error ? e.message : String(e), }) } }, [applyRulesText, rulesText]) const save = useCallback(async () => { try { const r = await api.saveRules(rulesText) toast.success(`Saved ${r.rule_count} rule${r.rule_count === 1 ? "" : "s"}`) } catch (e) { toast.error("Save failed", { description: e instanceof Error ? e.message : String(e), }) } }, [rulesText]) const setRuleEnabled = useCallback( async (ruleName: string, enabled: boolean) => { try { const result = await api.setRuleEnabled(rulesText, ruleName, enabled) await applyRulesText(result.rules_text) toast.success(`${enabled ? "Enabled" : "Disabled"} ${ruleName}`) } catch (e) { toast.error("Rule update failed", { description: e instanceof Error ? e.message : String(e), }) } }, [applyRulesText, rulesText], ) const deleteRule = useCallback( async (ruleName: string) => { try { const result = await api.deleteRule(rulesText, ruleName) await applyRulesText(result.rules_text) toast.success(`Deleted ${ruleName}`) } catch (e) { toast.error("Delete failed", { description: e instanceof Error ? e.message : String(e), }) } }, [applyRulesText, rulesText], ) const compile = useCallback( async (instruction: string): Promise => { if (compileInFlightRef.current) return null compileInFlightRef.current = true setCompiling(true) try { const c = await api.compileRules( instruction, params.classes, rulesText, compiler, ) await applyRulesText(c.rules_text) toast.success(`Added rule. ${c.rule_count} total.`) return c } catch (e) { toast.error("Compile failed", { description: e instanceof Error ? e.message : String(e), }) return null } finally { compileInFlightRef.current = false setCompiling(false) } }, [applyRulesText, compiler, params.classes, rulesText], ) const value = useMemo( () => ({ params, setParam, rulesText, setRulesText, replaceRulesText, videoFile, videoPreviewUrl, setVideo, result, running, error, run, validation, validate, save, setRuleEnabled, deleteRule, compiler, setCompilerProvider, setCompilerApiKey, setCompilerModel, compile, compiling, }), [ params, setParam, rulesText, replaceRulesText, videoFile, videoPreviewUrl, setVideo, result, running, error, run, validation, validate, save, setRuleEnabled, deleteRule, compiler, setCompilerModel, compile, compiling, ], ) return {children} } export function useDashboard(): DashboardState { const ctx = useContext(Ctx) if (!ctx) throw new Error("useDashboard must be used within DashboardProvider") return ctx }