Compare commits

...

6 Commits

Author SHA1 Message Date
shivammittal274
7b21e4557e fix: invalidate stale refine requests on dialog reopen and rename to kebab-case
- Increment refineRequestIdRef on dialog open so in-flight requests
  from a previous session are discarded when they complete
- Rename refinePrompt.ts to refine-prompt.ts per CLAUDE.md file naming
2026-03-17 15:43:01 +05:30
shivammittal274
45bf13d6f0 fix: ignore stale refine-prompt responses after dialog re-open
Use a request generation counter so that if the dialog is closed and
re-opened while a rewrite is in flight, the stale response is silently
discarded instead of overwriting the fresh form state.
2026-03-17 15:36:34 +05:30
shivammittal274
b8744fc037 fix: reset isRefining state on dialog re-open 2026-03-17 15:34:59 +05:30
shivammittal274
25d3444b88 fix: hide undo rewrite link while refinement is in flight 2026-03-17 15:19:25 +05:30
shivammittal274
120db3a142 fix: clear stale undo ref on dialog re-open and pass providerId to refinePrompt
- Reset originalPromptRef when dialog opens and on form submit to
  prevent stale "Undo rewrite" button on re-open
- Accept optional providerId in refinePrompt() so the form's selected
  provider is used for refinement instead of always the system default
2026-03-17 15:07:30 +05:30
shivammittal274
599ba2e6dd feat: add "Rewrite with AI" prompt refinement for scheduled tasks
Add a lightweight /refine-prompt endpoint that uses generateText to
rewrite rough scheduled task prompts into clear, actionable instructions.
The UI adds a sparkle-icon button next to the Prompt label in the
NewScheduledTaskDialog with loading state, undo support, and disabled
state when the textarea is empty.
2026-03-17 14:44:47 +05:30
7 changed files with 264 additions and 6 deletions

View File

@@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { ChevronDown } from 'lucide-react'
import { ChevronDown, Loader2, Sparkles, Undo2 } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod/v3'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
@@ -40,6 +40,10 @@ import {
providersStorage,
} from '@/lib/llm-providers/storage'
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
import { SCHEDULED_TASK_PROMPT_REFINED_EVENT } from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { refinePrompt } from '@/lib/schedules/refine-prompt'
import { toast } from 'sonner'
import type { ScheduledJob } from './types'
const formSchema = z
@@ -109,6 +113,10 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
const scheduleType = form.watch('scheduleType')
const selectedProviderId = form.watch('providerId')
const queryValue = form.watch('query')
const [isRefining, setIsRefining] = useState(false)
const originalPromptRef = useRef<string | null>(null)
const refineRequestIdRef = useRef(0)
// Load providers from storage
useEffect(() => {
@@ -124,6 +132,9 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
useEffect(() => {
if (open) {
refineRequestIdRef.current++
originalPromptRef.current = null
setIsRefining(false)
if (initialValues) {
form.reset({
name: initialValues.name,
@@ -168,6 +179,42 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
type: p.type,
}))
const handleRefinePrompt = async () => {
const currentQuery = form.getValues('query').trim()
const currentName = form.getValues('name').trim()
if (!currentQuery) return
const requestId = ++refineRequestIdRef.current
setIsRefining(true)
originalPromptRef.current = currentQuery
try {
const refined = await refinePrompt({
prompt: currentQuery,
name: currentName || 'Untitled Task',
providerId: form.getValues('providerId'),
})
if (requestId !== refineRequestIdRef.current) return
form.setValue('query', refined)
track(SCHEDULED_TASK_PROMPT_REFINED_EVENT)
} catch {
if (requestId !== refineRequestIdRef.current) return
toast.error('Failed to rewrite prompt. Please try again.')
originalPromptRef.current = null
} finally {
if (requestId === refineRequestIdRef.current) {
setIsRefining(false)
}
}
}
const handleUndoRefine = () => {
if (originalPromptRef.current !== null) {
form.setValue('query', originalPromptRef.current)
originalPromptRef.current = null
}
}
const onSubmit = (values: FormValues) => {
onSave({
name: values.name.trim(),
@@ -181,6 +228,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
enabled: values.enabled,
})
form.reset()
originalPromptRef.current = null
onOpenChange(false)
}
@@ -218,17 +266,51 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
name="query"
render={({ field }) => (
<FormItem>
<FormLabel>Prompt</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Prompt</FormLabel>
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto gap-1 px-2 py-1 text-xs text-muted-foreground"
disabled={!queryValue?.trim() || isRefining}
onClick={handleRefinePrompt}
>
{isRefining ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Sparkles className="h-3 w-3" />
)}
{isRefining ? 'Rewriting...' : 'Rewrite with AI'}
</Button>
</div>
<FormControl>
<Textarea
placeholder="What should the agent do? e.g., Check my email and summarize important messages"
className="min-h-[100px] resize-none"
{...field}
onChange={(e) => {
field.onChange(e)
if (originalPromptRef.current !== null) {
originalPromptRef.current = null
}
}}
/>
</FormControl>
<FormDescription>
The instruction that will be sent to the agent
</FormDescription>
{!isRefining && originalPromptRef.current !== null ? (
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
onClick={handleUndoRefine}
>
<Undo2 className="h-3 w-3" />
Undo rewrite
</button>
) : (
<FormDescription>
The instruction that will be sent to the agent
</FormDescription>
)}
<FormMessage />
</FormItem>
)}

View File

@@ -56,6 +56,10 @@ export const SCHEDULED_TASK_DELETED_EVENT = 'settings.scheduled_task.deleted'
/** @public */
export const SCHEDULED_TASK_TOGGLED_EVENT = 'settings.scheduled_task.toggled'
/** @public */
export const SCHEDULED_TASK_PROMPT_REFINED_EVENT =
'settings.scheduled_task.prompt_refined'
/** @public */
export const SCHEDULED_TASK_TESTED_EVENT = 'settings.scheduled_task.tested'

View File

@@ -0,0 +1,71 @@
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import {
createDefaultBrowserOSProvider,
defaultProviderIdStorage,
providersStorage,
} from '@/lib/llm-providers/storage'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
const resolveProvider = async (
providerId?: string,
): Promise<LlmProviderConfig> => {
const providers = await providersStorage.getValue()
if (providerId && providers?.length) {
const match = providers.find((p) => p.id === providerId)
if (match) return match
}
if (providers?.length) {
const defaultProviderId = await defaultProviderIdStorage.getValue()
const defaultProvider = providers.find((p) => p.id === defaultProviderId)
if (defaultProvider) return defaultProvider
if (providers[0]) return providers[0]
}
return createDefaultBrowserOSProvider()
}
interface RefinePromptResponse {
success: boolean
refined?: string
message?: string
}
export async function refinePrompt(params: {
prompt: string
name: string
providerId?: string
}): Promise<string> {
const agentServerUrl = await getAgentServerUrl()
const provider = await resolveProvider(params.providerId)
const response = await fetch(`${agentServerUrl}/refine-prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: params.prompt,
name: params.name,
provider: provider.type,
model: provider.modelId ?? 'default',
apiKey: provider.apiKey,
baseUrl: provider.baseUrl,
resourceName: provider.resourceName,
accessKeyId: provider.accessKeyId,
secretAccessKey: provider.secretAccessKey,
region: provider.region,
sessionToken: provider.sessionToken,
}),
})
if (!response.ok) {
const errorData = (await response
.json()
.catch(() => null)) as RefinePromptResponse | null
throw new Error(errorData?.message ?? `Request failed: ${response.status}`)
}
const data = (await response.json()) as RefinePromptResponse
if (!data.success || !data.refined) {
throw new Error(data.message ?? 'Failed to refine prompt')
}
return data.refined
}

View File

@@ -0,0 +1,36 @@
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import { z } from 'zod'
import { refinePrompt } from '../../lib/clients/llm/refine-prompt'
import { logger } from '../../lib/logger'
import { AgentLLMConfigSchema } from '../types'
const RefinePromptRequestSchema = AgentLLMConfigSchema.extend({
prompt: z.string().min(1, 'Prompt cannot be empty'),
name: z.string().min(1, 'Task name cannot be empty'),
})
export function createRefinePromptRoutes() {
return new Hono().post(
'/',
zValidator('json', RefinePromptRequestSchema),
async (c) => {
const { prompt, name, ...llmConfig } = c.req.valid('json')
logger.info('Refine prompt request', {
provider: llmConfig.provider,
model: llmConfig.model,
taskName: name,
})
const result = await refinePrompt(llmConfig, { prompt, name })
logger.info('Refine prompt result', {
provider: llmConfig.provider,
success: result.success,
})
return c.json(result, result.success ? 200 : 400)
},
)
}

View File

@@ -23,6 +23,7 @@ import { createKlavisRoutes } from './routes/klavis'
import { createMcpRoutes } from './routes/mcp'
import { createMemoryRoutes } from './routes/memory'
import { createProviderRoutes } from './routes/provider'
import { createRefinePromptRoutes } from './routes/refine-prompt'
import { createSdkRoutes } from './routes/sdk'
import { createShutdownRoute } from './routes/shutdown'
import { createSkillsRoutes } from './routes/skills'
@@ -113,6 +114,7 @@ export async function createHttpServer(config: HttpServerConfig) {
.route('/memory', createMemoryRoutes())
.route('/skills', createSkillsRoutes())
.route('/test-provider', createProviderRoutes())
.route('/refine-prompt', createRefinePromptRoutes())
.route('/klavis', createKlavisRoutes({ browserosId: browserosId || '' }))
.route(
'/mcp',

View File

@@ -0,0 +1,62 @@
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
import type { LLMConfig } from '@browseros/shared/schemas/llm'
import { generateText } from 'ai'
import { resolveLLMConfig } from './config'
import { createLLMProvider } from './provider'
export interface RefinePromptConfig extends LLMConfig {
model: string
upstreamProvider?: string
}
export interface RefinePromptRequest {
prompt: string
name: string
}
export interface RefinePromptResult {
success: boolean
refined?: string
message?: string
}
function buildSystemPrompt(name: string): string {
return `You are helping a user write a prompt for a scheduled browser automation task called "${name}".
This prompt will be executed automatically on a recurring schedule by an AI agent that can fully control a browser — navigate sites, click, type, read content, and take screenshots.
Rewrite the user's rough prompt into a clear, natural instruction. Make it:
- Specific about what to do and where (which websites, what pages, what to look for)
- Clear about what result to return at the end (a summary, key data points, changes detected, etc.)
- Complete enough to run unattended — the agent can't ask follow-up questions
If the user's prompt is too vague to fill in specifics, use natural placeholders like [your competitor's URL] that they can easily spot and replace.
Write it as a natural instruction — like telling a capable assistant what to do. Keep it concise. Return ONLY the rewritten prompt, nothing else.`
}
export async function refinePrompt(
llmConfig: RefinePromptConfig,
request: RefinePromptRequest,
): Promise<RefinePromptResult> {
try {
const resolvedConfig = await resolveLLMConfig(llmConfig)
const model = createLLMProvider(resolvedConfig)
const response = await generateText({
model,
system: buildSystemPrompt(request.name),
messages: [{ role: 'user', content: request.prompt }],
abortSignal: AbortSignal.timeout(TIMEOUTS.REFINE_PROMPT),
})
const refined = response.text?.trim()
if (!refined) {
return { success: false, message: 'Provider returned an empty response' }
}
return { success: true, refined }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, message: errorMessage }
}
}

View File

@@ -11,6 +11,7 @@ export const TIMEOUTS = {
TOOL_CALL: 120_000,
TOOL_POST_ACTION: 2_000,
TEST_PROVIDER: 15_000,
REFINE_PROMPT: 30_000,
// Controller communication
CONTROLLER_DEFAULT: 60_000,