mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
feat: scheduled tasks (#146)
* feat: scheduled tasks base ui * chore: fix biome version * fix: type issues * chore: remove use callback * chore: refactor scheduleStorage types * feat: create storage hooks for job & job runs * feat: integrate listing with store * feat: schedule tasks dialog integration * feat: integrate view and runs * feat: sync alarm state * fix: check for enabled jobs in alarm state * feat: createAlarmFromJob utility * feat: updated edit hooks to update alarms * feat: getChatServerResponse util * feat: run jobs in schedule * feat: update job run stat with storage * feat: discard old runs over 15 * feat: provide graph mode entry * feat: footer link with scheduler option * feat: use a nicer loader for task runs * feat: schedule results component * feat: scheduler results in new tab page * feat: nicer date formatting with dayjs * feat: use run-result-dialog for displaying run results in new tab * chore: delete mocked storage methods * chore: remove unused code * chore: remove all job runs when a job is deleted * feat: use shadcn elements for schedule results component * feat: render results in markdown view * chore: added important update on logic sharing * chore: remove loading state in scheduledtaskslist * feat: run the background job in a unfocused window * feat: provide mcp options to the background scheduled tasks * chore: clean up stale jobs on chrome restart or update * fix: background window not cleaned up on error Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * chore: fix type issues --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.9/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"vcs": {
|
||||
|
||||
120
apps/agent/components/ai-elements/run-result-dialog.tsx
Normal file
120
apps/agent/components/ai-elements/run-result-dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { Check, CheckCircle2, Copy, Loader2, XCircle } from 'lucide-react'
|
||||
import { marked } from 'marked'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
|
||||
|
||||
dayjs.extend(duration)
|
||||
|
||||
interface RunResultDialogProps {
|
||||
run: ScheduledJobRun | null
|
||||
jobName?: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const formatDateTime = (dateStr: string) =>
|
||||
dayjs(dateStr).format('MMM D, YYYY, h:mm A')
|
||||
|
||||
function formatDuration(startedAt: string, completedAt?: string): string {
|
||||
if (!completedAt) return 'Still running'
|
||||
const diff = dayjs(completedAt).diff(dayjs(startedAt))
|
||||
const d = dayjs.duration(diff)
|
||||
const mins = Math.floor(d.asMinutes())
|
||||
const secs = d.seconds()
|
||||
if (mins === 0) return `${secs} seconds`
|
||||
return `${mins}m ${secs}s`
|
||||
}
|
||||
|
||||
export const RunResultDialog: FC<RunResultDialogProps> = ({
|
||||
run,
|
||||
jobName,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
if (!run?.result) return null
|
||||
const html = marked.parse(run.result, { async: false }) as string
|
||||
return DOMPurify.sanitize(html)
|
||||
}, [run?.result])
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!run?.result) return
|
||||
await navigator.clipboard.writeText(run.result)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (!run) return null
|
||||
|
||||
return (
|
||||
<Dialog open={!!run} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{run.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : run.status === 'failed' ? (
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-accent-orange" />
|
||||
)}
|
||||
{jobName || 'Run Result'}
|
||||
</DialogTitle>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDateTime(run.startedAt)} •{' '}
|
||||
{formatDuration(run.startedAt, run.completedAt)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-100">
|
||||
{renderedContent ? (
|
||||
<div
|
||||
className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border bg-muted/50 p-4"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: renderedContent is sanitized with DOMPurify
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-4 text-muted-foreground text-sm">
|
||||
No result available
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
{run.result && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
className="mr-2 sm:mr-0"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => onOpenChange(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import { fetchMcpTools } from '@/lib/mcp/client'
|
||||
import { onServerMessage } from '@/lib/messaging/server/serverMessages'
|
||||
import { onOpenSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
|
||||
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
|
||||
import { scheduledJobRuns } from './scheduledJobRuns'
|
||||
|
||||
export default defineBackground(async () => {
|
||||
export default defineBackground(() => {
|
||||
chrome.sidePanel.setOptions({ enabled: false })
|
||||
|
||||
await Capabilities.initialize()
|
||||
Capabilities.initialize().catch(() => null)
|
||||
setupLlmProvidersBackupToBrowserOS()
|
||||
|
||||
scheduledJobRuns()
|
||||
|
||||
chrome.action.onClicked.addListener(async (tab) => {
|
||||
if (tab.id) {
|
||||
await toggleSidePanel(tab.id)
|
||||
158
apps/agent/entrypoints/background/scheduledJobRuns.ts
Normal file
158
apps/agent/entrypoints/background/scheduledJobRuns.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { createAlarmFromJob } from '@/lib/schedules/createAlarmFromJob'
|
||||
import { getChatServerResponse } from '@/lib/schedules/getChatServerResponse'
|
||||
import {
|
||||
scheduledJobRunStorage,
|
||||
scheduledJobStorage,
|
||||
} from '@/lib/schedules/scheduleStorage'
|
||||
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
|
||||
|
||||
const MAX_RUNS_PER_JOB = 15
|
||||
const STALE_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
export const scheduledJobRuns = async () => {
|
||||
const cleanupStaleJobRuns = async () => {
|
||||
const current = (await scheduledJobRunStorage.getValue()) ?? []
|
||||
const now = Date.now()
|
||||
|
||||
const updated = current.map((run) => {
|
||||
if (run.status !== 'running') return run
|
||||
|
||||
const startedAt = new Date(run.startedAt).getTime()
|
||||
if (now - startedAt > STALE_TIMEOUT_MS) {
|
||||
return {
|
||||
...run,
|
||||
status: 'failed' as const,
|
||||
completedAt: new Date().toISOString(),
|
||||
result: 'Job timed out!',
|
||||
}
|
||||
}
|
||||
return run
|
||||
})
|
||||
|
||||
await scheduledJobRunStorage.setValue(updated)
|
||||
}
|
||||
|
||||
const syncAlarmState = async () => {
|
||||
const jobs = (await scheduledJobStorage.getValue()).filter(
|
||||
(each) => each.enabled,
|
||||
)
|
||||
|
||||
for (let i = 0; i < jobs.length; i++) {
|
||||
const job = jobs[i]
|
||||
const alarmName = `scheduled-job-${job.id}`
|
||||
const existingAlarm = await chrome.alarms.get(alarmName)
|
||||
|
||||
if (!existingAlarm) {
|
||||
await createAlarmFromJob(job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createJobRun = async (
|
||||
jobId: string,
|
||||
status: ScheduledJobRun['status'],
|
||||
): Promise<ScheduledJobRun> => {
|
||||
const jobRun: ScheduledJobRun = {
|
||||
id: crypto.randomUUID(),
|
||||
jobId,
|
||||
startedAt: new Date().toISOString(),
|
||||
status,
|
||||
}
|
||||
|
||||
const current = (await scheduledJobRunStorage.getValue()) ?? []
|
||||
const otherJobRuns = current.filter((r) => r.jobId !== jobId)
|
||||
const thisJobRuns = current
|
||||
.filter((r) => r.jobId === jobId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
|
||||
)
|
||||
.slice(0, MAX_RUNS_PER_JOB - 1)
|
||||
|
||||
await scheduledJobRunStorage.setValue([
|
||||
...otherJobRuns,
|
||||
...thisJobRuns,
|
||||
jobRun,
|
||||
])
|
||||
return jobRun
|
||||
}
|
||||
|
||||
const updateJobRun = async (
|
||||
runId: string,
|
||||
updates: Partial<Omit<ScheduledJobRun, 'id' | 'jobId' | 'startedAt'>>,
|
||||
) => {
|
||||
const current = (await scheduledJobRunStorage.getValue()) ?? []
|
||||
await scheduledJobRunStorage.setValue(
|
||||
current.map((r) => (r.id === runId ? { ...r, ...updates } : r)),
|
||||
)
|
||||
}
|
||||
|
||||
const updateJobLastRunAt = async (jobId: string) => {
|
||||
const current = (await scheduledJobStorage.getValue()) ?? []
|
||||
await scheduledJobStorage.setValue(
|
||||
current.map((j) =>
|
||||
j.id === jobId ? { ...j, lastRunAt: new Date().toISOString() } : j,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (!alarm.name.startsWith('scheduled-job-')) return
|
||||
|
||||
const jobId = alarm.name.replace('scheduled-job-', '')
|
||||
|
||||
const job = (await scheduledJobStorage.getValue()).find(
|
||||
(each) => each.id === jobId,
|
||||
)
|
||||
|
||||
if (!job) return
|
||||
|
||||
const backgroundWindow = await chrome.windows.create({
|
||||
url: 'chrome://newtab',
|
||||
focused: false,
|
||||
state: 'minimized',
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
const backgroundTab = backgroundWindow?.tabs?.[0]
|
||||
|
||||
if (!backgroundWindow || !backgroundTab) return
|
||||
|
||||
const jobRun = await createJobRun(jobId, 'running')
|
||||
|
||||
try {
|
||||
const response = await getChatServerResponse({
|
||||
message: job.query,
|
||||
activeTab: backgroundTab,
|
||||
windowId: backgroundWindow.id,
|
||||
})
|
||||
|
||||
await updateJobRun(jobRun.id, {
|
||||
status: 'completed',
|
||||
completedAt: new Date().toISOString(),
|
||||
result: response.text,
|
||||
})
|
||||
} catch (e) {
|
||||
await updateJobRun(jobRun.id, {
|
||||
status: 'failed',
|
||||
completedAt: new Date().toISOString(),
|
||||
result: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
await updateJobLastRunAt(jobId)
|
||||
if (backgroundWindow.id) {
|
||||
await chrome.windows.remove(backgroundWindow.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
chrome.runtime.onStartup.addListener(async () => {
|
||||
await cleanupStaleJobRuns()
|
||||
await syncAlarmState()
|
||||
})
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async () => {
|
||||
await cleanupStaleJobRuns()
|
||||
await syncAlarmState()
|
||||
})
|
||||
}
|
||||
56
apps/agent/entrypoints/newtab/index/FooterLinks.tsx
Normal file
56
apps/agent/entrypoints/newtab/index/FooterLinks.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Calendar } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
|
||||
interface FooterLinksProps {
|
||||
onOpenShortcuts: () => void
|
||||
}
|
||||
|
||||
export const FooterLinks: FC<FooterLinksProps> = ({ onOpenShortcuts }) => {
|
||||
const { supports } = useCapabilities()
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-4 pt-4">
|
||||
<a
|
||||
href="/options.html#/scheduled"
|
||||
className="group inline-flex flex-row gap-2 text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
>
|
||||
<Calendar className="h-4 w-4 transition-colors group-hover:text-accent-orange" />
|
||||
Scheduler{' '}
|
||||
<span className="text-accent-orange group-hover:text-accent-orange-bright">
|
||||
(new)
|
||||
</span>
|
||||
</a>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
|
||||
{supports(Feature.MANAGED_MCP_SUPPORT) && (
|
||||
<>
|
||||
<a
|
||||
href="/options.html#/connect-mcp"
|
||||
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
>
|
||||
Connect MCP servers{' '}
|
||||
</a>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href="/options.html"
|
||||
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onOpenShortcuts}
|
||||
className="hover:no-underline! px-0! text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
>
|
||||
Shortcuts
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,14 +18,13 @@ import {
|
||||
import { TabSelector } from '@/components/elements/tab-selector'
|
||||
import { ThemeToggle } from '@/components/elements/theme-toggle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import {
|
||||
createAITabAction,
|
||||
createBrowserOSAction,
|
||||
} from '@/lib/chat-actions/types'
|
||||
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FooterLinks } from './FooterLinks'
|
||||
import type { SuggestionItem } from './lib/suggestions/types'
|
||||
import {
|
||||
getSuggestionLabel,
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
} from './lib/suggestions/useSuggestions'
|
||||
import { NewTabBranding } from './NewTabBranding'
|
||||
import { NewTabFocusGrid } from './NewTabFocusGrid'
|
||||
import { ScheduleResults } from './ScheduleResults'
|
||||
import { SearchSuggestions } from './SearchSuggestions'
|
||||
import { ShortcutsDialog } from './ShortcutsDialog'
|
||||
import { TopSites } from './TopSites'
|
||||
@@ -56,7 +56,6 @@ export const NewTab = () => {
|
||||
const tabsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [shortcutsDialogOpen, setShortcutsDialogOpen] = useState(false)
|
||||
const { supports } = useCapabilities()
|
||||
|
||||
const toggleTab = (tab: chrome.tabs.Tab) => {
|
||||
setSelectedTabs((prev) => {
|
||||
@@ -419,53 +418,11 @@ export const NewTab = () => {
|
||||
{/* Top sites */}
|
||||
{!isSuggestionsVisible && <TopSites />}
|
||||
|
||||
{/* Footer links */}
|
||||
{!isSuggestionsVisible && (
|
||||
<div className="flex items-center justify-center gap-4 pt-4">
|
||||
<a
|
||||
href="/options.html"
|
||||
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setShortcutsDialogOpen(true)}
|
||||
className="hover:no-underline! px-0! text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
>
|
||||
Shortcuts
|
||||
</Button>
|
||||
|
||||
{supports(Feature.MANAGED_MCP_SUPPORT) && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<a
|
||||
href="/options.html#/connect-mcp"
|
||||
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
>
|
||||
Connect MCP servers{' '}
|
||||
<span className="text-[var(--accent-orange)]">(new)</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{/*<span className="text-muted-foreground">•</span>
|
||||
<a
|
||||
href="/settings"
|
||||
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
>
|
||||
Shortcuts
|
||||
</a>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<a
|
||||
href="/settings"
|
||||
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||
>
|
||||
Personalize
|
||||
</a>*/}
|
||||
</div>
|
||||
<FooterLinks onOpenShortcuts={() => setShortcutsDialogOpen(true)} />
|
||||
)}
|
||||
|
||||
{mounted && !isSuggestionsVisible && <ScheduleResults />}
|
||||
</div>
|
||||
{mounted && (
|
||||
<ShortcutsDialog
|
||||
|
||||
152
apps/agent/entrypoints/newtab/index/ScheduleResults.tsx
Normal file
152
apps/agent/entrypoints/newtab/index/ScheduleResults.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { RunResultDialog } from '@/components/ai-elements/run-result-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
useScheduledJobRuns,
|
||||
useScheduledJobs,
|
||||
} from '@/lib/schedules/scheduleStorage'
|
||||
import type {
|
||||
ScheduledJob,
|
||||
ScheduledJobRun,
|
||||
} from '@/lib/schedules/scheduleTypes'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
interface JobRunWithDetails extends ScheduledJobRun {
|
||||
job: ScheduledJob | undefined
|
||||
}
|
||||
|
||||
const MAX_DISPLAY_COUNT = 3
|
||||
|
||||
const getStatusIcon = (status: JobRunWithDetails['status']) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
case 'running':
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-accent-orange" />
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-destructive" />
|
||||
}
|
||||
}
|
||||
|
||||
const formatTimestamp = (dateString: string) => dayjs(dateString).fromNow()
|
||||
|
||||
export const ScheduleResults: FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [viewingRun, setViewingRun] = useState<JobRunWithDetails | null>(null)
|
||||
|
||||
const { jobRuns } = useScheduledJobRuns()
|
||||
const { jobs } = useScheduledJobs()
|
||||
|
||||
const runningCount = jobRuns.filter((r) => r.status === 'running').length
|
||||
|
||||
const displayedRuns: JobRunWithDetails[] = useMemo(() => {
|
||||
const enrichWithJob = (run: ScheduledJobRun): JobRunWithDetails => ({
|
||||
...run,
|
||||
job: jobs.find((j) => j.id === run.jobId),
|
||||
})
|
||||
|
||||
const runningJobs = jobRuns
|
||||
.filter((r) => r.status === 'running')
|
||||
.map(enrichWithJob)
|
||||
|
||||
if (runningJobs.length >= MAX_DISPLAY_COUNT) {
|
||||
return runningJobs
|
||||
}
|
||||
|
||||
const completedOrFailed = jobRuns
|
||||
.filter((r) => r.status === 'completed' || r.status === 'failed')
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
|
||||
)
|
||||
.slice(0, MAX_DISPLAY_COUNT - runningJobs.length)
|
||||
.map(enrichWithJob)
|
||||
|
||||
return [...runningJobs, ...completedOrFailed]
|
||||
}, [jobRuns, jobs])
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className="mb-16 space-y-3"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group flex h-auto w-full items-center justify-between rounded-xl border border-border/50 bg-card/50 p-3 transition-all hover:border-border hover:bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
Scheduler Outputs
|
||||
</span>
|
||||
{runningCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{runningCount} running
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="fade-in-0 slide-in-from-top-2 animate-in space-y-2 duration-200">
|
||||
{displayedRuns.map((run) => (
|
||||
<Button
|
||||
key={run.id}
|
||||
variant="ghost"
|
||||
onClick={() => setViewingRun(run)}
|
||||
className="h-auto w-full justify-start rounded-xl border border-border/50 bg-card p-4 text-left transition-all hover:border-border"
|
||||
>
|
||||
<div className="flex w-full items-start gap-3">
|
||||
{getStatusIcon(run.status)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{run.job?.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTimestamp(run.startedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{run.result && (
|
||||
<p className="line-clamp-2 text-ellipsis text-muted-foreground text-xs">
|
||||
{run.result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
|
||||
<RunResultDialog
|
||||
run={viewingRun}
|
||||
jobName={viewingRun?.job?.name}
|
||||
onOpenChange={(open) => !open && setViewingRun(null)}
|
||||
/>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ConnectMCP } from './connect-mcp/ConnectMCP'
|
||||
import { DashboardLayout } from './layout/DashboardLayout'
|
||||
import { LlmHubPage } from './llm-hub/LlmHubPage'
|
||||
import { MCPSettingsPage } from './mcp-settings/MCPSettingsPage'
|
||||
import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
|
||||
|
||||
export const App: FC = () => {
|
||||
return (
|
||||
@@ -21,6 +22,7 @@ export const App: FC = () => {
|
||||
path="onboarding"
|
||||
element={<AISettingsPage key="onboarding" />}
|
||||
/>
|
||||
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Bot,
|
||||
CalendarClock,
|
||||
Info,
|
||||
type LucideIcon,
|
||||
Menu,
|
||||
@@ -52,6 +53,12 @@ const navigationItems: NavItem[] = [
|
||||
enabled: true,
|
||||
feature: Feature.MANAGED_MCP_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Scheduled Tasks',
|
||||
to: '/scheduled',
|
||||
icon: CalendarClock,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'BrowserOS MCP',
|
||||
to: '/mcp',
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { ScheduledJob } from './types'
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.max(100, 'Name must be 100 characters or less'),
|
||||
query: z
|
||||
.string()
|
||||
.min(1, 'Query is required')
|
||||
.max(2000, 'Query must be 2000 characters or less'),
|
||||
scheduleType: z.enum(['daily', 'hourly', 'minutes']),
|
||||
scheduleTime: z.string().optional(),
|
||||
scheduleInterval: z.number().int().min(1).max(60).optional(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.scheduleType === 'daily' && !data.scheduleTime) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Time is required for daily schedule',
|
||||
path: ['scheduleTime'],
|
||||
})
|
||||
}
|
||||
if (
|
||||
(data.scheduleType === 'hourly' || data.scheduleType === 'minutes') &&
|
||||
(!data.scheduleInterval || data.scheduleInterval < 1)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Interval must be at least 1',
|
||||
path: ['scheduleInterval'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
|
||||
interface NewScheduledTaskDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
initialValues?: ScheduledJob | null
|
||||
onSave: (data: Omit<ScheduledJob, 'id' | 'createdAt'>) => void
|
||||
}
|
||||
|
||||
export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialValues,
|
||||
onSave,
|
||||
}) => {
|
||||
const isEditing = !!initialValues
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
query: '',
|
||||
scheduleType: 'daily',
|
||||
scheduleTime: '09:00',
|
||||
scheduleInterval: 1,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const scheduleType = form.watch('scheduleType')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (initialValues) {
|
||||
form.reset({
|
||||
name: initialValues.name,
|
||||
query: initialValues.query,
|
||||
scheduleType: initialValues.scheduleType,
|
||||
scheduleTime: initialValues.scheduleTime || '09:00',
|
||||
scheduleInterval: initialValues.scheduleInterval || 1,
|
||||
enabled: initialValues.enabled,
|
||||
})
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
query: '',
|
||||
scheduleType: 'daily',
|
||||
scheduleTime: '09:00',
|
||||
scheduleInterval: 1,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [open, initialValues, form])
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
onSave({
|
||||
name: values.name.trim(),
|
||||
query: values.query.trim(),
|
||||
scheduleType: values.scheduleType,
|
||||
scheduleTime:
|
||||
values.scheduleType === 'daily' ? values.scheduleTime : undefined,
|
||||
scheduleInterval:
|
||||
values.scheduleType !== 'daily' ? values.scheduleInterval : undefined,
|
||||
enabled: values.enabled,
|
||||
})
|
||||
form.reset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? 'Edit Scheduled Task' : 'Create Scheduled Task'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? 'Update your scheduled task configuration.'
|
||||
: 'Create a new task that runs automatically on a schedule.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., Morning Briefing" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="query"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Query</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What should the agent do? e.g., Check my email and summarize important messages"
|
||||
className="min-h-[100px] resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The instruction that will be sent to the agent
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduleType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Schedule</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select schedule type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily at time</SelectItem>
|
||||
<SelectItem value="hourly">Every N hours</SelectItem>
|
||||
<SelectItem value="minutes">Every N minutes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{scheduleType === 'daily' ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduleTime"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Time</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="time" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduleInterval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Interval (
|
||||
{scheduleType === 'hourly' ? 'hours' : 'minutes'})
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={scheduleType === 'hourly' ? 24 : 60}
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value
|
||||
? Number(e.target.value)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Enable this task
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">{isEditing ? 'Update' : 'Create'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useScheduledJobRuns } from '@/lib/schedules/scheduleStorage'
|
||||
import type { ScheduledJob, ScheduledJobRun } from './types'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(duration)
|
||||
|
||||
interface ScheduledTaskCardProps {
|
||||
job: ScheduledJob
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onToggle: (enabled: boolean) => void
|
||||
onViewRun: (run: ScheduledJobRun) => void
|
||||
}
|
||||
|
||||
function formatSchedule(job: ScheduledJob): string {
|
||||
if (job.scheduleType === 'daily' && job.scheduleTime) {
|
||||
return `Daily at ${job.scheduleTime}`
|
||||
}
|
||||
if (job.scheduleType === 'hourly' && job.scheduleInterval) {
|
||||
return job.scheduleInterval === 1
|
||||
? 'Every hour'
|
||||
: `Every ${job.scheduleInterval} hours`
|
||||
}
|
||||
if (job.scheduleType === 'minutes' && job.scheduleInterval) {
|
||||
return job.scheduleInterval === 1
|
||||
? 'Every minute'
|
||||
: `Every ${job.scheduleInterval} minutes`
|
||||
}
|
||||
return 'Not scheduled'
|
||||
}
|
||||
|
||||
const formatRelativeTime = (dateStr: string) => dayjs(dateStr).fromNow()
|
||||
|
||||
function formatDuration(startedAt: string, completedAt?: string): string {
|
||||
if (!completedAt) return 'Running...'
|
||||
const diff = dayjs(completedAt).diff(dayjs(startedAt))
|
||||
const d = dayjs.duration(diff)
|
||||
const mins = Math.floor(d.asMinutes())
|
||||
const secs = d.seconds()
|
||||
if (mins === 0) return `${secs}s`
|
||||
return `${mins}m ${secs}s`
|
||||
}
|
||||
|
||||
const formatRunDate = (dateStr: string) =>
|
||||
dayjs(dateStr).format('MMM D, h:mm A')
|
||||
|
||||
export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
|
||||
job,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onViewRun,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const { jobRuns } = useScheduledJobRuns()
|
||||
|
||||
const runs = useMemo(
|
||||
() =>
|
||||
jobRuns
|
||||
.filter((run) => run.jobId === job.id)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
|
||||
),
|
||||
[jobRuns, job.id],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:border-[var(--accent-orange)]/50 hover:shadow-sm">
|
||||
<div className="flex items-start gap-4">
|
||||
<Switch
|
||||
checked={job.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
aria-label={`${job.enabled ? 'Disable' : 'Enable'} ${job.name}`}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="truncate font-semibold">{job.name}</span>
|
||||
{!job.enabled && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-2 line-clamp-1 text-muted-foreground text-sm">
|
||||
"{job.query}"
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<span>{formatSchedule(job)}</span>
|
||||
{job.lastRunAt && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>Last run: {formatRelativeTime(job.lastRunAt)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<Pencil className="mr-1.5 h-3 w-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={`Delete ${job.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runs.length > 0 && (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="mt-4">
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 text-muted-foreground text-sm hover:text-foreground">
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
<span>Run History ({runs.length})</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<div className="space-y-2">
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-background p-3"
|
||||
>
|
||||
{run.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
|
||||
) : run.status === 'failed' ? (
|
||||
<XCircle className="h-4 w-4 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent-orange" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm">
|
||||
{formatRunDate(run.startedAt)}
|
||||
</span>
|
||||
<span className="ml-2 text-muted-foreground text-xs">
|
||||
{formatDuration(run.startedAt, run.completedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onViewRun(run)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CalendarClock, Plus } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ScheduledTasksHeaderProps {
|
||||
onAddClick: () => void
|
||||
}
|
||||
|
||||
export const ScheduledTasksHeader: FC<ScheduledTasksHeaderProps> = ({
|
||||
onAddClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
|
||||
<CalendarClock className="h-6 w-6 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="mb-1 font-semibold text-xl">Scheduled Tasks</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Automate recurring browser tasks
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onAddClick}
|
||||
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FC } from 'react'
|
||||
import { ScheduledTaskCard } from './ScheduledTaskCard'
|
||||
import type { ScheduledJob, ScheduledJobRun } from './types'
|
||||
|
||||
interface ScheduledTasksListProps {
|
||||
jobs: ScheduledJob[]
|
||||
onEdit: (job: ScheduledJob) => void
|
||||
onDelete: (jobId: string) => void
|
||||
onToggle: (jobId: string, enabled: boolean) => void
|
||||
onViewRun: (run: ScheduledJobRun) => void
|
||||
}
|
||||
|
||||
export const ScheduledTasksList: FC<ScheduledTasksListProps> = ({
|
||||
jobs,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onViewRun,
|
||||
}) => {
|
||||
if (jobs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="rounded-lg border border-border border-dashed py-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No scheduled tasks yet. Create one to automate recurring workflows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{jobs.map((job) => (
|
||||
<ScheduledTaskCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onEdit={() => onEdit(job)}
|
||||
onDelete={() => onDelete(job.id)}
|
||||
onToggle={(enabled) => onToggle(job.id, enabled)}
|
||||
onViewRun={onViewRun}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { type FC, useState } from 'react'
|
||||
import { RunResultDialog } from '@/components/ai-elements/run-result-dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useScheduledJobs } from '@/lib/schedules/scheduleStorage'
|
||||
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
|
||||
import { NewScheduledTaskDialog } from './NewScheduledTaskDialog'
|
||||
import { ScheduledTasksHeader } from './ScheduledTasksHeader'
|
||||
import { ScheduledTasksList } from './ScheduledTasksList'
|
||||
import type { ScheduledJob } from './types'
|
||||
|
||||
/**
|
||||
* Main page for managing scheduled tasks
|
||||
* @public
|
||||
*/
|
||||
export const ScheduledTasksPage: FC = () => {
|
||||
const { jobs, addJob, editJob, toggleJob, removeJob } = useScheduledJobs()
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [editingJob, setEditingJob] = useState<ScheduledJob | null>(null)
|
||||
const [deleteJobId, setDeleteJobId] = useState<string | null>(null)
|
||||
const [viewingRun, setViewingRun] = useState<ScheduledJobRun | null>(null)
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingJob(null)
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEdit = (job: ScheduledJob) => {
|
||||
setEditingJob(job)
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = (jobId: string) => {
|
||||
setDeleteJobId(jobId)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteJobId) {
|
||||
await removeJob(deleteJobId)
|
||||
setDeleteJobId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (data: Omit<ScheduledJob, 'id' | 'createdAt'>) => {
|
||||
if (editingJob) {
|
||||
await editJob(editingJob.id, data)
|
||||
} else {
|
||||
await addJob(data)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (jobId: string, enabled: boolean) => {
|
||||
await toggleJob(jobId, enabled)
|
||||
}
|
||||
|
||||
const handleViewRun = (run: ScheduledJobRun) => {
|
||||
setViewingRun(run)
|
||||
}
|
||||
|
||||
const jobToDelete = deleteJobId
|
||||
? jobs.find((j) => j.id === deleteJobId)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
|
||||
<ScheduledTasksHeader onAddClick={handleAdd} />
|
||||
|
||||
<ScheduledTasksList
|
||||
jobs={jobs}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onToggle={handleToggle}
|
||||
onViewRun={handleViewRun}
|
||||
/>
|
||||
|
||||
<NewScheduledTaskDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
initialValues={editingJob}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
<RunResultDialog
|
||||
run={viewingRun}
|
||||
jobName={
|
||||
viewingRun
|
||||
? jobs.find((j) => j.id === viewingRun.jobId)?.name
|
||||
: undefined
|
||||
}
|
||||
onOpenChange={(open) => !open && setViewingRun(null)}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteJobId !== null}
|
||||
onOpenChange={(open) => !open && setDeleteJobId(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Scheduled Task</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Delete "{jobToDelete?.name}"? This will also remove all run
|
||||
history for this task.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
apps/agent/entrypoints/options/scheduled-tasks/types.ts
Normal file
13
apps/agent/entrypoints/options/scheduled-tasks/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
ScheduledJob,
|
||||
ScheduledJobRun,
|
||||
} from '@/lib/schedules/scheduleTypes'
|
||||
|
||||
export type { ScheduledJob, ScheduledJobRun }
|
||||
|
||||
export interface ScheduledTasksStorage {
|
||||
loadJobs(): Promise<ScheduledJob[]>
|
||||
saveJobs(jobs: ScheduledJob[]): Promise<void>
|
||||
loadRuns(): Promise<ScheduledJobRun[]>
|
||||
saveRuns(runs: ScheduledJobRun[]): Promise<void>
|
||||
}
|
||||
@@ -177,6 +177,7 @@ export const useChatSession = () => {
|
||||
error: chatError,
|
||||
} = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
// Important: this chat logic is also used in apps/agent/lib/schedules/getChatServerResponse.ts for scheduled jobs. Make sure to keep them in sync for any future changes.
|
||||
prepareSendMessagesRequest: async ({ messages }) => {
|
||||
const activeTabsList = await chrome.tabs.query({
|
||||
active: true,
|
||||
|
||||
43
apps/agent/lib/schedules/createAlarmFromJob.ts
Normal file
43
apps/agent/lib/schedules/createAlarmFromJob.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ScheduledJob } from './scheduleTypes'
|
||||
|
||||
const getNextScheduledTime = (timeString: string): number => {
|
||||
const [hours, minutes] = timeString.split(':').map(Number)
|
||||
const now = new Date()
|
||||
const scheduled = new Date()
|
||||
|
||||
scheduled.setHours(hours, minutes, 0, 0)
|
||||
|
||||
// If time has passed today, schedule for tomorrow
|
||||
if (scheduled.getTime() <= now.getTime()) {
|
||||
scheduled.setDate(scheduled.getDate() + 1)
|
||||
}
|
||||
|
||||
return scheduled.getTime()
|
||||
}
|
||||
|
||||
export const createAlarmFromJob = async (job: ScheduledJob) => {
|
||||
const alarmName = `scheduled-job-${job.id}`
|
||||
|
||||
let time: chrome.alarms.AlarmCreateInfo | undefined
|
||||
|
||||
if (job.scheduleType === 'daily') {
|
||||
time = {
|
||||
when: getNextScheduledTime(job.scheduleTime!),
|
||||
periodInMinutes: 24 * 60, // Repeat every 24 hours
|
||||
}
|
||||
} else if (job.scheduleType === 'hourly') {
|
||||
const intervalInMinutes = job.scheduleInterval! * 60
|
||||
time = {
|
||||
delayInMinutes: intervalInMinutes,
|
||||
periodInMinutes: intervalInMinutes,
|
||||
}
|
||||
} else if (job.scheduleType === 'minutes') {
|
||||
time = {
|
||||
delayInMinutes: job.scheduleInterval,
|
||||
periodInMinutes: job.scheduleInterval,
|
||||
}
|
||||
}
|
||||
if (time) {
|
||||
await chrome.alarms.create(alarmName, time)
|
||||
}
|
||||
}
|
||||
161
apps/agent/lib/schedules/getChatServerResponse.ts
Normal file
161
apps/agent/lib/schedules/getChatServerResponse.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import {
|
||||
defaultProviderIdStorage,
|
||||
providersStorage,
|
||||
} from '@/lib/llm-providers/storage'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import { mcpServerStorage } from '@/lib/mcp/mcpServerStorage'
|
||||
|
||||
interface ActiveTab {
|
||||
id?: number
|
||||
url?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface ChatServerRequest {
|
||||
message: string
|
||||
mode?: ChatMode
|
||||
conversationId?: string
|
||||
windowId?: number
|
||||
activeTab?: ActiveTab
|
||||
}
|
||||
|
||||
interface ChatServerResponse {
|
||||
text: string
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
interface StreamEvent {
|
||||
type: string
|
||||
delta?: string
|
||||
}
|
||||
|
||||
const getDefaultProvider = async (): Promise<LlmProviderConfig | null> => {
|
||||
const providers = await providersStorage.getValue()
|
||||
if (!providers?.length) return null
|
||||
|
||||
const defaultProviderId = await defaultProviderIdStorage.getValue()
|
||||
const defaultProvider = providers.find((p) => p.id === defaultProviderId)
|
||||
return defaultProvider ?? providers[0] ?? null
|
||||
}
|
||||
|
||||
export async function getChatServerResponse(
|
||||
request: ChatServerRequest,
|
||||
): Promise<ChatServerResponse> {
|
||||
const agentServerUrl = await getAgentServerUrl()
|
||||
const provider = await getDefaultProvider()
|
||||
const conversationId = request.conversationId ?? crypto.randomUUID()
|
||||
|
||||
const mcpServers = (await mcpServerStorage.getValue()) ?? []
|
||||
const enabledMcpServers = mcpServers
|
||||
.filter((s) => s.type === 'managed')
|
||||
.map((s) => s.managedServerName)
|
||||
.filter((name): name is string => !!name)
|
||||
const customMcpServers = mcpServers
|
||||
.filter((s) => s.type === 'custom' && s.config?.url)
|
||||
.map((s) => ({ name: s.displayName, url: s.config!.url! }))
|
||||
|
||||
const response = await fetch(`${agentServerUrl}/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Important: this chat logic is also used in apps/agent/entrypoints/sidepanel/index/useChatSession.ts for sidepanel conversation. Make sure to keep them in sync for any future changes.
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: 'user', content: request.message }],
|
||||
message: request.message,
|
||||
provider: provider?.type,
|
||||
providerType: provider?.type,
|
||||
providerName: provider?.name,
|
||||
apiKey: provider?.apiKey,
|
||||
baseUrl: provider?.baseUrl,
|
||||
conversationId,
|
||||
model: provider?.modelId ?? 'default',
|
||||
mode: request.mode ?? 'agent',
|
||||
resourceName: provider?.resourceName,
|
||||
accessKeyId: provider?.accessKeyId,
|
||||
secretAccessKey: provider?.secretAccessKey,
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
browserContext:
|
||||
request.activeTab ||
|
||||
request.windowId ||
|
||||
enabledMcpServers.length ||
|
||||
customMcpServers.length
|
||||
? {
|
||||
windowId: request.windowId,
|
||||
activeTab: request.activeTab,
|
||||
enabledMcpServers:
|
||||
enabledMcpServers.length > 0 ? enabledMcpServers : undefined,
|
||||
customMcpServers:
|
||||
customMcpServers.length > 0 ? customMcpServers : undefined,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Chat request failed: ${response.status} ${response.statusText}`,
|
||||
)
|
||||
}
|
||||
|
||||
const text = await parseSSEStream(response)
|
||||
|
||||
return { text, conversationId }
|
||||
}
|
||||
|
||||
async function parseSSEStream(response: Response): Promise<string> {
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error('Response body is not readable')
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let result = ''
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
|
||||
const data = line.slice(6)
|
||||
if (data === '[DONE]') continue
|
||||
|
||||
try {
|
||||
const event: StreamEvent = JSON.parse(data)
|
||||
if (event.type === 'text-delta' && event.delta) {
|
||||
result += event.delta
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining buffer
|
||||
if (buffer.startsWith('data: ')) {
|
||||
const data = buffer.slice(6)
|
||||
if (data !== '[DONE]') {
|
||||
try {
|
||||
const event: StreamEvent = JSON.parse(data)
|
||||
if (event.type === 'text-delta' && event.delta) {
|
||||
result += event.delta
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
133
apps/agent/lib/schedules/scheduleStorage.ts
Normal file
133
apps/agent/lib/schedules/scheduleStorage.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createAlarmFromJob } from './createAlarmFromJob'
|
||||
import type { ScheduledJob, ScheduledJobRun } from './scheduleTypes'
|
||||
|
||||
const getAlarmName = (jobId: string) => `scheduled-job-${jobId}`
|
||||
|
||||
export const scheduledJobStorage = storage.defineItem<ScheduledJob[]>(
|
||||
'local:scheduledJobs',
|
||||
{
|
||||
fallback: [],
|
||||
},
|
||||
)
|
||||
|
||||
export const scheduledJobRunStorage = storage.defineItem<ScheduledJobRun[]>(
|
||||
'local:scheduledJobRuns',
|
||||
{
|
||||
fallback: [],
|
||||
},
|
||||
)
|
||||
|
||||
export function useScheduledJobs() {
|
||||
const [jobs, setJobs] = useState<ScheduledJob[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
scheduledJobStorage.getValue().then(setJobs)
|
||||
const unwatch = scheduledJobStorage.watch((newValue) => {
|
||||
setJobs(newValue ?? [])
|
||||
})
|
||||
return unwatch
|
||||
}, [])
|
||||
|
||||
const addJob = async (job: Omit<ScheduledJob, 'id' | 'createdAt'>) => {
|
||||
const newJob: ScheduledJob = {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...job,
|
||||
}
|
||||
const current = (await scheduledJobStorage.getValue()) ?? []
|
||||
await scheduledJobStorage.setValue([...current, newJob])
|
||||
|
||||
if (newJob.enabled) {
|
||||
await createAlarmFromJob(newJob)
|
||||
}
|
||||
}
|
||||
|
||||
const removeJob = async (id: string) => {
|
||||
await chrome.alarms.clear(getAlarmName(id))
|
||||
|
||||
const currentJobs = (await scheduledJobStorage.getValue()) ?? []
|
||||
await scheduledJobStorage.setValue(currentJobs.filter((j) => j.id !== id))
|
||||
|
||||
const currentRuns = (await scheduledJobRunStorage.getValue()) ?? []
|
||||
await scheduledJobRunStorage.setValue(
|
||||
currentRuns.filter((r) => r.jobId !== id),
|
||||
)
|
||||
}
|
||||
|
||||
const toggleJob = async (id: string, enabled: boolean) => {
|
||||
const current = (await scheduledJobStorage.getValue()) ?? []
|
||||
const job = current.find((j) => j.id === id)
|
||||
if (!job) return
|
||||
|
||||
await scheduledJobStorage.setValue(
|
||||
current.map((j) => (j.id === id ? { ...j, enabled } : j)),
|
||||
)
|
||||
|
||||
if (enabled) {
|
||||
await createAlarmFromJob({ ...job, enabled })
|
||||
} else {
|
||||
await chrome.alarms.clear(getAlarmName(id))
|
||||
}
|
||||
}
|
||||
|
||||
const editJob = async (
|
||||
id: string,
|
||||
updates: Omit<ScheduledJob, 'id' | 'createdAt'>,
|
||||
) => {
|
||||
const current = (await scheduledJobStorage.getValue()) ?? []
|
||||
const existingJob = current.find((j) => j.id === id)
|
||||
if (!existingJob) return
|
||||
|
||||
const updatedJob: ScheduledJob = {
|
||||
id,
|
||||
createdAt: existingJob.createdAt,
|
||||
...updates,
|
||||
}
|
||||
await scheduledJobStorage.setValue(
|
||||
current.map((j) => (j.id === id ? updatedJob : j)),
|
||||
)
|
||||
|
||||
await chrome.alarms.clear(getAlarmName(id))
|
||||
if (updatedJob.enabled) {
|
||||
await createAlarmFromJob(updatedJob)
|
||||
}
|
||||
}
|
||||
|
||||
return { jobs, addJob, removeJob, editJob, toggleJob }
|
||||
}
|
||||
|
||||
export function useScheduledJobRuns() {
|
||||
const [jobRuns, setJobRuns] = useState<ScheduledJobRun[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
scheduledJobRunStorage.getValue().then(setJobRuns)
|
||||
const unwatch = scheduledJobRunStorage.watch((newValue) => {
|
||||
setJobRuns(newValue ?? [])
|
||||
})
|
||||
return unwatch
|
||||
}, [])
|
||||
|
||||
const addJobRun = async (jobRun: ScheduledJobRun) => {
|
||||
const current = (await scheduledJobRunStorage.getValue()) ?? []
|
||||
await scheduledJobRunStorage.setValue([...current, jobRun])
|
||||
}
|
||||
|
||||
const removeJobRun = async (id: string) => {
|
||||
const current = (await scheduledJobRunStorage.getValue()) ?? []
|
||||
await scheduledJobRunStorage.setValue(current.filter((r) => r.id !== id))
|
||||
}
|
||||
|
||||
const editJobRun = async (
|
||||
id: string,
|
||||
updates: Partial<Omit<ScheduledJobRun, 'id'>>,
|
||||
) => {
|
||||
const current = (await scheduledJobRunStorage.getValue()) ?? []
|
||||
await scheduledJobRunStorage.setValue(
|
||||
current.map((r) => (r.id === id ? { ...r, ...updates } : r)),
|
||||
)
|
||||
}
|
||||
|
||||
return { jobRuns, addJobRun, removeJobRun, editJobRun }
|
||||
}
|
||||
20
apps/agent/lib/schedules/scheduleTypes.ts
Normal file
20
apps/agent/lib/schedules/scheduleTypes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface ScheduledJob {
|
||||
id: string
|
||||
name: string
|
||||
query: string
|
||||
scheduleType: 'daily' | 'hourly' | 'minutes'
|
||||
scheduleTime?: string
|
||||
scheduleInterval?: number
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
lastRunAt?: string
|
||||
}
|
||||
|
||||
export interface ScheduledJobRun {
|
||||
id: string
|
||||
jobId: string
|
||||
startedAt: string
|
||||
completedAt?: string
|
||||
status: 'running' | 'completed' | 'failed'
|
||||
result?: string
|
||||
}
|
||||
@@ -49,11 +49,14 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"downshift": "^9.0.10",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"es-toolkit": "^1.42.0",
|
||||
"klavis": "^2.15.0",
|
||||
"lucide-react": "^0.554.0",
|
||||
"marked": "^17.0.1",
|
||||
"motion": "^12.23.24",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -75,7 +78,7 @@
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.9",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/chrome": "^0.1.28",
|
||||
"@types/react": "^19.1.12",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "tailwind-scrollbar-hide/v4";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ export default defineConfig({
|
||||
`https://${PRODUCT_WEB_HOST}/*`,
|
||||
`https://*.${PRODUCT_WEB_HOST}/*`,
|
||||
],
|
||||
// @ts-expect-error - extension_ids type is missing in wxt types
|
||||
extension_ids: [LEGACY_AGENT_EXTENSION_ID],
|
||||
},
|
||||
],
|
||||
@@ -39,7 +38,14 @@ export default defineConfig({
|
||||
},
|
||||
default_title: 'Ask BrowserOS',
|
||||
},
|
||||
permissions: ['topSites', 'tabs', 'storage', 'sidePanel', 'browserOS'],
|
||||
permissions: [
|
||||
'topSites',
|
||||
'tabs',
|
||||
'storage',
|
||||
'sidePanel',
|
||||
'browserOS',
|
||||
'alarms',
|
||||
],
|
||||
host_permissions: [
|
||||
'http://127.0.0.1/*',
|
||||
'https://suggestqueries.google.com/*',
|
||||
|
||||
35
bun.lock
35
bun.lock
@@ -49,11 +49,14 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"downshift": "^9.0.10",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"es-toolkit": "^1.42.0",
|
||||
"klavis": "^2.15.0",
|
||||
"lucide-react": "^0.554.0",
|
||||
"marked": "^17.0.1",
|
||||
"motion": "^12.23.24",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -75,7 +78,7 @@
|
||||
"zod": "^4.1.13",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.9",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/chrome": "^0.1.28",
|
||||
"@types/react": "^19.1.12",
|
||||
@@ -942,6 +945,8 @@
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||
|
||||
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
|
||||
@@ -1448,6 +1453,8 @@
|
||||
|
||||
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
@@ -2208,7 +2215,7 @@
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
|
||||
|
||||
@@ -2546,6 +2553,8 @@
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||
|
||||
"postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
|
||||
@@ -3326,6 +3335,8 @@
|
||||
|
||||
"@google/gemini-cli-core/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"@google/gemini-cli-core/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"@google/genai/google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
@@ -3342,8 +3353,6 @@
|
||||
|
||||
"@lobehub/ui/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
|
||||
|
||||
"@lobehub/ui/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"@lobehub/ui/url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="],
|
||||
|
||||
"@lobehub/ui/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
|
||||
@@ -3554,8 +3563,6 @@
|
||||
|
||||
"accepts/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
|
||||
|
||||
"agent/@biomejs/biome": ["@biomejs/biome@2.3.9", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.9", "@biomejs/cli-darwin-x64": "2.3.9", "@biomejs/cli-linux-arm64": "2.3.9", "@biomejs/cli-linux-arm64-musl": "2.3.9", "@biomejs/cli-linux-x64": "2.3.9", "@biomejs/cli-linux-x64-musl": "2.3.9", "@biomejs/cli-win32-arm64": "2.3.9", "@biomejs/cli-win32-x64": "2.3.9" }, "bin": { "biome": "bin/biome" } }, "sha512-js+34KpnY65I00k8P71RH0Uh2rJk4BrpxMGM5m2nBfM9XTlKE5N1URn5ydILPRyXXq4ebhKCjsvR+txS+D4z2A=="],
|
||||
|
||||
"agent/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
|
||||
@@ -3938,22 +3945,6 @@
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"agent/@biomejs/biome/@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hHbYYnna/WBwem5iCpssQQLtm5ey8ADuDT8N2zqosk6LVWimlEuUnPy6Mbzgu4GWVriyL5ijWd+1zphX6ll4/A=="],
|
||||
|
||||
"agent/@biomejs/biome/@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-sKMW5fpvGDmPdqCchtVH5MVlbVeSU3ad4CuKS45x8VHt3tNSC8CZ2QbxffAOKYK9v/mAeUiPC6Cx6+wtyU1q7g=="],
|
||||
|
||||
"agent/@biomejs/biome/@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-BXBB6HbAgZI6T6QB8q6NSwIapVngqArP6K78BqkMerht7YjL6yWctqfeTnJm0qGF2bKBYFexslrbV+VTlM2E6g=="],
|
||||
|
||||
"agent/@biomejs/biome/@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JOHyG2nl8XDpncbMazm1uBSi1dPX9VbQDOjKcfSVXTqajD0PsgodMOKyuZ/PkBu5Lw877sWMTGKfEfpM7jE7Cw=="],
|
||||
|
||||
"agent/@biomejs/biome/@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-PjYuv2WLmvf0WtidxAkFjlElsn0P6qcvfPijrqu1j+3GoW3XSQh3ywGu7gZ25J25zrYj3KEovUjvUZB55ATrGw=="],
|
||||
|
||||
"agent/@biomejs/biome/@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-FUkb/5beCIC2trpqAbW9e095X4vamdlju80c1ExSmhfdrojLZnWkah/XfTSixKb/dQzbAjpD7vvs6rWkJ+P07Q=="],
|
||||
|
||||
"agent/@biomejs/biome/@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-w48Yh/XbYHO2cBw8B5laK3vCAEKuocX5ItGXVDAqFE7Ze2wnR00/1vkY6GXglfRDOjWHu2XtxI0WKQ52x1qxEA=="],
|
||||
|
||||
"agent/@biomejs/biome/@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-90+J63VT7qImy9s3pkWL0ZX27VzVwMNCRzpLpe5yMzMYPbO1vcjL/w/Q5f/juAGMvP7a2Fd0H7IhAR6F7/i78A=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
Reference in New Issue
Block a user