tiny-trigger / frontend /src /lib /dashboard.tsx
Javier Montalvo
Add demo video link to README; frontend and server updates
015fde7
Raw
History Blame Contribute Delete
11.4 kB
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
}