| 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: <K extends keyof DetectParams>(key: K, value: DetectParams[K]) => void |
| rulesText: string |
| setRulesText: (text: string) => void |
| replaceRulesText: (text: string, persist?: boolean) => Promise<ValidationResult> |
| videoFile: File | null |
| videoPreviewUrl: string | null |
| setVideo: (file: File | null) => void |
| result: RunResult | null |
| running: boolean |
| error: string | null |
| run: () => Promise<void> |
| validation: ValidationResult | null |
| validate: () => Promise<void> |
| save: () => Promise<void> |
| setRuleEnabled: (ruleName: string, enabled: boolean) => Promise<void> |
| deleteRule: (ruleName: string) => Promise<void> |
| compiler: CloudCompilerConfig |
| setCompilerProvider: (provider: CompilerProvider) => void |
| setCompilerApiKey: (provider: CompilerProvider, apiKey: string) => void |
| setCompilerModel: (provider: "openai" | "anthropic", model: string) => void |
| compile: (instruction: string) => Promise<CompileResult | null> |
| compiling: boolean |
| } |
|
|
| const Ctx = createContext<DashboardState | null>(null) |
|
|
| export function DashboardProvider({ children }: { children: ReactNode }) { |
| const [params, setParams] = useState<DetectParams>(DEFAULT_PARAMS) |
| const [rulesText, setRulesText] = useState<string>(DEFAULT_RULES) |
| const [videoFile, setVideoFile] = useState<File | null>(null) |
| const [videoPreviewUrl, setVideoPreviewUrl] = useState<string | null>(null) |
| const [result, setResult] = useState<RunResult | null>(null) |
| const [running, setRunning] = useState(false) |
| const [error, setError] = useState<string | null>(null) |
| const [validation, setValidation] = useState<ValidationResult | null>(null) |
| const [compiler, setCompiler] = useState<CloudCompilerConfig>(() => ({ |
| ...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<string | null>(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], |
| ) |
|
|
| |
| 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( |
| <K extends keyof DetectParams>(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<CompilerProvider, keyof CloudCompilerConfig> = { |
| 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<CompileResult | null> => { |
| 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<DashboardState>( |
| () => ({ |
| 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 <Ctx.Provider value={value}>{children}</Ctx.Provider> |
| } |
|
|
| export function useDashboard(): DashboardState { |
| const ctx = useContext(Ctx) |
| if (!ctx) throw new Error("useDashboard must be used within DashboardProvider") |
| return ctx |
| } |
|
|