Spaces:
Sleeping
Sleeping
| 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], | |
| ) | |
| // 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( | |
| <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 | |
| } | |