mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
feat: add per-task LLM provider selection for scheduled tasks (#450)
* feat: add per-task LLM provider selection for scheduled tasks Allow users to choose which AI provider a scheduled task runs with, using the same ChatProviderSelector component from the new-tab page. Falls back to the global default provider when none is selected or if the selected provider has been deleted. * fix: lint issues * chore: updated to latest schema.graphql file --------- Co-authored-by: Dani Akash <DaniAkash@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod/v3'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
@@ -31,6 +34,12 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import {
|
||||
defaultProviderIdStorage,
|
||||
providersStorage,
|
||||
} from '@/lib/llm-providers/storage'
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
import type { ScheduledJob } from './types'
|
||||
|
||||
const formSchema = z
|
||||
@@ -43,6 +52,7 @@ const formSchema = z
|
||||
scheduleType: z.enum(['daily', 'hourly', 'minutes']),
|
||||
scheduleTime: z.string().optional(),
|
||||
scheduleInterval: z.number().int().min(1).max(60).optional(),
|
||||
providerId: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
@@ -81,6 +91,8 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
onSave,
|
||||
}) => {
|
||||
const isEditing = !!initialValues
|
||||
const [providers, setProviders] = useState<LlmProviderConfig[]>([])
|
||||
const [defaultProviderId, setDefaultProviderId] = useState<string>('')
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -90,11 +102,25 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
scheduleType: 'daily',
|
||||
scheduleTime: '09:00',
|
||||
scheduleInterval: 1,
|
||||
providerId: undefined,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const scheduleType = form.watch('scheduleType')
|
||||
const selectedProviderId = form.watch('providerId')
|
||||
|
||||
// Load providers from storage
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
Promise.all([
|
||||
providersStorage.getValue(),
|
||||
defaultProviderIdStorage.getValue(),
|
||||
]).then(([providerList, defId]) => {
|
||||
setProviders(providerList ?? [])
|
||||
setDefaultProviderId(defId ?? '')
|
||||
})
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -105,6 +131,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
scheduleType: initialValues.scheduleType,
|
||||
scheduleTime: initialValues.scheduleTime || '09:00',
|
||||
scheduleInterval: initialValues.scheduleInterval || 1,
|
||||
providerId: initialValues.providerId,
|
||||
enabled: initialValues.enabled,
|
||||
})
|
||||
} else {
|
||||
@@ -114,12 +141,33 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
scheduleType: 'daily',
|
||||
scheduleTime: '09:00',
|
||||
scheduleInterval: 1,
|
||||
providerId: undefined,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [open, initialValues, form])
|
||||
|
||||
// Resolve the currently selected provider for the selector display
|
||||
const resolvedProvider: Provider | null = (() => {
|
||||
const id = selectedProviderId ?? defaultProviderId
|
||||
const found = providers.find((p) => p.id === id)
|
||||
if (found) return { id: found.id, name: found.name, type: found.type }
|
||||
if (providers[0])
|
||||
return {
|
||||
id: providers[0].id,
|
||||
name: providers[0].name,
|
||||
type: providers[0].type,
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
const providerOptions: Provider[] = providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
}))
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
onSave({
|
||||
name: values.name.trim(),
|
||||
@@ -129,6 +177,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
values.scheduleType === 'daily' ? values.scheduleTime : undefined,
|
||||
scheduleInterval:
|
||||
values.scheduleType !== 'daily' ? values.scheduleInterval : undefined,
|
||||
providerId: values.providerId,
|
||||
enabled: values.enabled,
|
||||
})
|
||||
form.reset()
|
||||
@@ -185,6 +234,43 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{providers.length > 0 && resolvedProvider && (
|
||||
<FormItem>
|
||||
<FormLabel>AI Provider</FormLabel>
|
||||
<ChatProviderSelector
|
||||
providers={providerOptions}
|
||||
selectedProvider={resolvedProvider}
|
||||
onSelectProvider={(provider) =>
|
||||
form.setValue('providerId', provider.id)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
{resolvedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={16} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
type={resolvedProvider.type as ProviderType}
|
||||
size={16}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{resolvedProvider.name}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</ChatProviderSelector>
|
||||
<FormDescription>
|
||||
The AI provider used to run this task
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import { providersStorage } from '@/lib/llm-providers/storage'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
import { useScheduledJobRuns } from '@/lib/schedules/scheduleStorage'
|
||||
import type { ScheduledJob, ScheduledJobRun } from './types'
|
||||
|
||||
@@ -80,9 +83,25 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
|
||||
onRetryRun,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [providerInfo, setProviderInfo] = useState<{
|
||||
name: string
|
||||
type: ProviderType
|
||||
} | null>(null)
|
||||
|
||||
const { jobRuns } = useScheduledJobRuns()
|
||||
|
||||
// Load provider info for display
|
||||
useEffect(() => {
|
||||
if (!job.providerId) {
|
||||
setProviderInfo(null)
|
||||
return
|
||||
}
|
||||
providersStorage.getValue().then((providers) => {
|
||||
const match = providers?.find((p) => p.id === job.providerId)
|
||||
setProviderInfo(match ? { name: match.name, type: match.type } : null)
|
||||
})
|
||||
}, [job.providerId])
|
||||
|
||||
const runs = useMemo(
|
||||
() =>
|
||||
jobRuns
|
||||
@@ -117,6 +136,19 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<span>{formatSchedule(job)}</span>
|
||||
{providerInfo && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{providerInfo.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={12} />
|
||||
) : (
|
||||
<ProviderIcon type={providerInfo.type} size={12} />
|
||||
)}
|
||||
{providerInfo.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{job.lastRunAt && (
|
||||
<>
|
||||
<span>•</span>
|
||||
|
||||
@@ -117,6 +117,7 @@ export const scheduledJobRuns = async () => {
|
||||
const response = await getChatServerResponse({
|
||||
message: job.query,
|
||||
signal: abortController.signal,
|
||||
providerId: job.providerId,
|
||||
})
|
||||
|
||||
await updateJobRun(jobRun.id, {
|
||||
|
||||
@@ -209,7 +209,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
})
|
||||
const activeTab = activeTabsList?.[0] ?? undefined
|
||||
const message = getLastMessageText(messages)
|
||||
const provider = selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
|
||||
const provider =
|
||||
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
|
||||
const currentMode = modeRef.current
|
||||
const enabledMcpServers = enabledMcpServersRef.current
|
||||
const customMcpServers = enabledCustomServersRef.current
|
||||
|
||||
@@ -158,9 +158,7 @@ export function useLlmProviders(): UseLlmProvidersReturn {
|
||||
// Fall back to first provider if defaultProviderId is stale/invalid
|
||||
const selectedProvider = useMemo(
|
||||
() =>
|
||||
providers.find((p) => p.id === defaultProviderId) ??
|
||||
providers[0] ??
|
||||
null,
|
||||
providers.find((p) => p.id === defaultProviderId) ?? providers[0] ?? null,
|
||||
[providers, defaultProviderId],
|
||||
)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ChatServerRequest {
|
||||
windowId?: number
|
||||
activeTab?: ActiveTab
|
||||
signal?: AbortSignal
|
||||
providerId?: string
|
||||
}
|
||||
|
||||
interface ChatServerResponse {
|
||||
@@ -75,11 +76,23 @@ const getDefaultProvider = async (): Promise<LlmProviderConfig | null> => {
|
||||
return defaultProvider ?? providers[0] ?? null
|
||||
}
|
||||
|
||||
// Resolve provider by ID, falling back to global default
|
||||
const resolveProvider = async (
|
||||
providerId?: string,
|
||||
): Promise<LlmProviderConfig> => {
|
||||
if (providerId) {
|
||||
const providers = await providersStorage.getValue()
|
||||
const match = providers?.find((p) => p.id === providerId)
|
||||
if (match) return match
|
||||
}
|
||||
return (await getDefaultProvider()) ?? createDefaultBrowserOSProvider()
|
||||
}
|
||||
|
||||
export async function getChatServerResponse(
|
||||
request: ChatServerRequest,
|
||||
): Promise<ChatServerResponse> {
|
||||
const agentServerUrl = await getAgentServerUrl()
|
||||
const provider = (await getDefaultProvider()) ?? createDefaultBrowserOSProvider()
|
||||
const provider = await resolveProvider(request.providerId)
|
||||
const conversationId = request.conversationId ?? crypto.randomUUID()
|
||||
const personalization = await personalizationStorage.getValue()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export const GetScheduledJobsByProfileIdDocument = graphql(`
|
||||
scheduleTime
|
||||
scheduleInterval
|
||||
enabled
|
||||
llmProviderId
|
||||
createdAt
|
||||
updatedAt
|
||||
lastRunAt
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ScheduledJob {
|
||||
scheduleTime?: string
|
||||
scheduleInterval?: number
|
||||
enabled: boolean
|
||||
providerId?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastRunAt?: string
|
||||
|
||||
@@ -19,6 +19,7 @@ type RemoteScheduledJob = {
|
||||
scheduleTime: string | null
|
||||
scheduleInterval: number | null
|
||||
enabled: boolean
|
||||
llmProviderId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastRunAt: string | null
|
||||
@@ -32,6 +33,7 @@ function toComparable(job: ScheduledJob) {
|
||||
...data,
|
||||
scheduleTime: data.scheduleTime ?? null,
|
||||
scheduleInterval: data.scheduleInterval ?? null,
|
||||
providerId: data.providerId ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +45,7 @@ function remoteToComparable(job: RemoteScheduledJob) {
|
||||
scheduleTime: job.scheduleTime,
|
||||
scheduleInterval: job.scheduleInterval,
|
||||
enabled: job.enabled,
|
||||
providerId: job.llmProviderId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +62,7 @@ function remoteToLocal(remote: RemoteScheduledJob): ScheduledJob {
|
||||
scheduleTime: remote.scheduleTime ?? undefined,
|
||||
scheduleInterval: remote.scheduleInterval ?? undefined,
|
||||
enabled: remote.enabled,
|
||||
providerId: remote.llmProviderId ?? undefined,
|
||||
createdAt: normalizeTimestamp(remote.createdAt),
|
||||
updatedAt: normalizeTimestamp(remote.updatedAt),
|
||||
lastRunAt: remote.lastRunAt
|
||||
@@ -163,6 +167,7 @@ export async function syncSchedulesToBackend(
|
||||
scheduleTime: job.scheduleTime ?? null,
|
||||
scheduleInterval: job.scheduleInterval ?? null,
|
||||
enabled: job.enabled,
|
||||
llmProviderId: job.providerId ?? null,
|
||||
lastRunAt: job.lastRunAt
|
||||
? new Date(job.lastRunAt).toISOString()
|
||||
: null,
|
||||
@@ -182,6 +187,7 @@ export async function syncSchedulesToBackend(
|
||||
scheduleTime: job.scheduleTime ?? null,
|
||||
scheduleInterval: job.scheduleInterval ?? null,
|
||||
enabled: job.enabled,
|
||||
llmProviderId: job.providerId ?? null,
|
||||
createdAt: new Date(job.createdAt).toISOString(),
|
||||
updatedAt: job.updatedAt || new Date().toISOString(),
|
||||
lastRunAt: job.lastRunAt
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user