mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
27 Commits
multi-tab-
...
fix/browse
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0ffbe255f | ||
|
|
951eb7e66d | ||
|
|
8548bcf50a | ||
|
|
e3601bfdc1 | ||
|
|
2b4fdf1aad | ||
|
|
11d15d079f | ||
|
|
9257832acf | ||
|
|
7bde0d59fa | ||
|
|
1c737b0f02 | ||
|
|
5d0a2b9bfe | ||
|
|
720baaed3e | ||
|
|
cee9c764b1 | ||
|
|
7bdeeb85d5 | ||
|
|
19069cb9c4 | ||
|
|
5bb6143373 | ||
|
|
f4d4b73a24 | ||
|
|
d965698905 | ||
|
|
50b2f45590 | ||
|
|
1b88ade021 | ||
|
|
079a254fa4 | ||
|
|
42aa0ff1ef | ||
|
|
4000f094f6 | ||
|
|
151be81cee | ||
|
|
46a8326140 | ||
|
|
4b18723a21 | ||
|
|
4909927c03 | ||
|
|
22c5e85707 |
5
.github/workflows/code-quality.yml
vendored
5
.github/workflows/code-quality.yml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'packages/browseros-agent/**'
|
||||
- "packages/browseros-agent/**"
|
||||
|
||||
jobs:
|
||||
biome:
|
||||
@@ -50,6 +50,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Prepare wxt
|
||||
run: VITE_PUBLIC_BROWSEROS_API=http://localhost:3000 bun run --cwd apps/agent wxt prepare
|
||||
|
||||
- name: Run codegen
|
||||
run: bun run --cwd apps/agent codegen
|
||||
|
||||
|
||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"terminal.integrated.tabs.title": "${sequence} ${process}",
|
||||
"terminal.integrated.tabs.description": "${cwd}"
|
||||
}
|
||||
57
lefthook.yml
Normal file
57
lefthook.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
commit-msg:
|
||||
commands:
|
||||
conventional:
|
||||
run: |
|
||||
msg=$(head -1 {1})
|
||||
if [[ ! "$msg" =~ ^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?\!?:\ .+ ]]; then
|
||||
echo "Commit message must follow Conventional Commits format:"
|
||||
echo " <type>(<optional scope>): <description>"
|
||||
echo " Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " feat(auth): add OAuth2 support"
|
||||
echo " fix: resolve null pointer exception"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pre-commit:
|
||||
commands:
|
||||
biome-check:
|
||||
root: "packages/browseros-agent/"
|
||||
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
|
||||
run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
|
||||
stage_fixed: true
|
||||
|
||||
file-length:
|
||||
root: "packages/browseros-agent/"
|
||||
glob: "*.{ts,tsx}"
|
||||
exclude: "*.{test,spec,d}.ts|*.{test,spec}.tsx|**/__tests__/**|**/tests/**|**/*.generated.*"
|
||||
run: |
|
||||
for file in {staged_files}; do
|
||||
if [[ -f "$file" ]]; then
|
||||
lines=$(wc -l < "$file" | tr -d ' ')
|
||||
if [[ $lines -gt 400 ]]; then
|
||||
echo "⚠️ Warning: $file has $lines lines (threshold: 400)"
|
||||
echo " Consider splitting this file if it has multiple responsibilities."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
pre-push:
|
||||
commands:
|
||||
branch-name:
|
||||
run: |
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [[ "$branch" == "main" || "$branch" == "master" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ ! "$branch" =~ ^(feat|fix|bugfix|hotfix|release|docs|refactor|test|chore|experiment)/[a-z0-9-]+$ ]]; then
|
||||
echo "⚠️ Warning: Branch name '$branch' doesn't match recommended format."
|
||||
echo " Use: <type>/<short-description>"
|
||||
echo " Types: feat, fix, bugfix, hotfix, release, docs, refactor, test, chore, experiment"
|
||||
echo " Example: feat/add-auth, fix/login-crash"
|
||||
echo ""
|
||||
echo " To rename your branch:"
|
||||
echo " git branch -m <new-name>"
|
||||
echo " git push -u origin <new-name>"
|
||||
fi
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"vcs": {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Coins } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { getCreditTextColor } from '@/lib/credits/credit-colors'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CreditBadgeProps {
|
||||
credits: number
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export const CreditBadge: FC<CreditBadgeProps> = ({ credits, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-xs transition-colors hover:bg-muted/50',
|
||||
getCreditTextColor(credits),
|
||||
)}
|
||||
title={`${credits} credits remaining`}
|
||||
>
|
||||
<Coins className="h-3.5 w-3.5" />
|
||||
<span>{credits}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
Compass,
|
||||
CreditCard,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Palette,
|
||||
@@ -79,6 +80,12 @@ const primarySettingsSections: NavSection[] = [
|
||||
feature: Feature.CUSTOMIZATION_SUPPORT,
|
||||
},
|
||||
{ name: 'BrowserOS as MCP', to: '/settings/mcp', icon: Server },
|
||||
{
|
||||
name: 'Usage & Billing',
|
||||
to: '/settings/usage',
|
||||
icon: CreditCard,
|
||||
feature: Feature.CREDITS_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Workflows',
|
||||
to: '/workflows',
|
||||
|
||||
@@ -176,14 +176,14 @@ function AlertDialogCancel({
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
|
||||
@@ -72,4 +72,4 @@ function AlertDescription({
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertDescription, AlertTitle }
|
||||
|
||||
@@ -104,10 +104,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
}
|
||||
|
||||
@@ -251,10 +251,10 @@ function CarouselNext({
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
}
|
||||
|
||||
@@ -39,4 +39,4 @@ function CollapsibleContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
export { Collapsible, CollapsibleContent, CollapsibleTrigger }
|
||||
|
||||
@@ -198,11 +198,11 @@ function CommandShortcut({
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
}
|
||||
|
||||
@@ -283,18 +283,18 @@ function DropdownMenuSubContent({
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
}
|
||||
|
||||
@@ -179,12 +179,12 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useFormField,
|
||||
}
|
||||
|
||||
@@ -50,4 +50,4 @@ function HoverCardContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger }
|
||||
|
||||
@@ -184,7 +184,7 @@ export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
|
||||
@@ -55,4 +55,4 @@ function PopoverAnchor({
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }
|
||||
|
||||
@@ -49,4 +49,4 @@ function ResizableHandle({
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
|
||||
@@ -129,11 +129,11 @@ function SheetDescription({
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
closeButton
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
|
||||
@@ -86,4 +86,4 @@ function TabsContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }
|
||||
|
||||
@@ -68,4 +68,4 @@ function TooltipContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FC } from 'react'
|
||||
import { HashRouter, Navigate, Route, Routes, useParams } from 'react-router'
|
||||
|
||||
import { NewTab } from '../newtab/index/NewTab'
|
||||
import { NewTabChat } from '../newtab/index/NewTabChat'
|
||||
import { NewTabLayout } from '../newtab/layout/NewTabLayout'
|
||||
import { Personalize } from '../newtab/personalize/Personalize'
|
||||
import { OnboardingDemo } from '../onboarding/demo/OnboardingDemo'
|
||||
@@ -27,6 +28,7 @@ import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
|
||||
import { SearchProviderPage } from './search-provider/SearchProviderPage'
|
||||
import { SkillsPage } from './skills/SkillsPage'
|
||||
import { SoulPage } from './soul/SoulPage'
|
||||
import { UsagePage } from './usage/UsagePage'
|
||||
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
|
||||
|
||||
function getSurveyParams(): { maxTurns?: number; experimentId?: string } {
|
||||
@@ -79,6 +81,7 @@ export const App: FC = () => {
|
||||
{/* Home routes */}
|
||||
<Route path="home" element={<NewTabLayout />}>
|
||||
<Route index element={<NewTab />} />
|
||||
<Route path="chat" element={<NewTabChat />} />
|
||||
<Route path="personalize" element={<Personalize />} />
|
||||
<Route path="soul" element={<SoulPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
@@ -101,6 +104,7 @@ export const App: FC = () => {
|
||||
<Route path="customization" element={<CustomizationPage />} />
|
||||
<Route path="search" element={<SearchProviderPage />} />
|
||||
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
|
||||
<Route path="usage" element={<UsagePage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -13,6 +13,17 @@ import {
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useSessionInfo } from '@/lib/auth/sessionStorage'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import {
|
||||
CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
|
||||
CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
|
||||
CHATGPT_PRO_OAUTH_STARTED_EVENT,
|
||||
GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
|
||||
GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
|
||||
GITHUB_COPILOT_OAUTH_STARTED_EVENT,
|
||||
QWEN_CODE_OAUTH_COMPLETED_EVENT,
|
||||
QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
|
||||
QWEN_CODE_OAUTH_STARTED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
|
||||
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
|
||||
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
|
||||
@@ -21,6 +32,11 @@ import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates'
|
||||
import { testProvider } from '@/lib/llm-providers/testProvider'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import {
|
||||
type OAuthProviderFlowConfig,
|
||||
useOAuthProviderFlow,
|
||||
} from '@/lib/llm-providers/useOAuthProviderFlow'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
|
||||
import {
|
||||
DeleteRemoteLlmProviderDocument,
|
||||
@@ -32,6 +48,47 @@ import { LlmProvidersHeader } from './LlmProvidersHeader'
|
||||
import { NewProviderDialog } from './NewProviderDialog'
|
||||
import { ProviderTemplatesSection } from './ProviderTemplatesSection'
|
||||
|
||||
// All OAuth providers share the same flow via useOAuthProviderFlow
|
||||
const OAUTH_PROVIDERS_CONFIG: Record<string, OAuthProviderFlowConfig> = {
|
||||
'chatgpt-pro': {
|
||||
providerType: 'chatgpt-pro',
|
||||
displayName: 'ChatGPT Plus/Pro',
|
||||
startedEvent: CHATGPT_PRO_OAUTH_STARTED_EVENT,
|
||||
completedEvent: CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
|
||||
disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
|
||||
},
|
||||
'github-copilot': {
|
||||
providerType: 'github-copilot',
|
||||
displayName: 'GitHub Copilot',
|
||||
startedEvent: GITHUB_COPILOT_OAUTH_STARTED_EVENT,
|
||||
completedEvent: GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
|
||||
disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
|
||||
clientAuth: {
|
||||
deviceCodeEndpoint: 'https://github.com/login/device/code',
|
||||
tokenEndpoint: 'https://github.com/login/oauth/access_token',
|
||||
clientId: 'Ov23li8tweQw6odWQebz',
|
||||
scopes: 'read:user',
|
||||
requiresPKCE: false,
|
||||
contentType: 'json',
|
||||
},
|
||||
},
|
||||
'qwen-code': {
|
||||
providerType: 'qwen-code',
|
||||
displayName: 'Qwen Code',
|
||||
startedEvent: QWEN_CODE_OAUTH_STARTED_EVENT,
|
||||
completedEvent: QWEN_CODE_OAUTH_COMPLETED_EVENT,
|
||||
disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
|
||||
clientAuth: {
|
||||
deviceCodeEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/device/code',
|
||||
tokenEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/token',
|
||||
clientId: 'f0304373b74a44d2b584a3fb70ca9e56',
|
||||
scopes: 'openid profile email model.completion',
|
||||
requiresPKCE: true,
|
||||
contentType: 'form',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Settings page for managing LLM providers
|
||||
* @public
|
||||
@@ -78,9 +135,7 @@ export const AISettingsPage: FC = () => {
|
||||
|
||||
const incompleteProviders = useMemo<IncompleteProvider[]>(() => {
|
||||
if (!remoteProvidersData?.llmProviders?.nodes) return []
|
||||
|
||||
const localProviderIds = new Set(providers.map((p) => p.id))
|
||||
|
||||
return remoteProvidersData.llmProviders.nodes
|
||||
.filter((node): node is NonNullable<typeof node> => node !== null)
|
||||
.filter((node) => !localProviderIds.has(node.rowId))
|
||||
@@ -101,12 +156,61 @@ export const AISettingsPage: FC = () => {
|
||||
null,
|
||||
)
|
||||
|
||||
// OAuth flows — shared hook eliminates per-provider duplication
|
||||
const chatgptPro = useOAuthProviderFlow(
|
||||
OAUTH_PROVIDERS_CONFIG['chatgpt-pro'],
|
||||
providers,
|
||||
saveProvider,
|
||||
)
|
||||
const copilot = useOAuthProviderFlow(
|
||||
OAUTH_PROVIDERS_CONFIG['github-copilot'],
|
||||
providers,
|
||||
saveProvider,
|
||||
)
|
||||
const qwenCode = useOAuthProviderFlow(
|
||||
OAUTH_PROVIDERS_CONFIG['qwen-code'],
|
||||
providers,
|
||||
saveProvider,
|
||||
)
|
||||
|
||||
const oauthFlows: Record<
|
||||
string,
|
||||
{
|
||||
startOAuthFlow: (url: string | undefined) => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
disconnectedEvent: string
|
||||
}
|
||||
> = {
|
||||
'chatgpt-pro': {
|
||||
startOAuthFlow: chatgptPro.startOAuthFlow,
|
||||
disconnect: chatgptPro.disconnect,
|
||||
disconnectedEvent: CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
|
||||
},
|
||||
'github-copilot': {
|
||||
startOAuthFlow: copilot.startOAuthFlow,
|
||||
disconnect: copilot.disconnect,
|
||||
disconnectedEvent: GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
|
||||
},
|
||||
'qwen-code': {
|
||||
startOAuthFlow: qwenCode.startOAuthFlow,
|
||||
disconnect: qwenCode.disconnect,
|
||||
disconnectedEvent: QWEN_CODE_OAUTH_DISCONNECTED_EVENT,
|
||||
},
|
||||
}
|
||||
|
||||
const handleAddProvider = () => {
|
||||
setTemplateValues(undefined)
|
||||
setIsNewDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleUseTemplate = (template: ProviderTemplate) => {
|
||||
// OAuth providers: trigger OAuth flow
|
||||
const oauthFlow = oauthFlows[template.id]
|
||||
if (oauthFlow) {
|
||||
oauthFlow.startOAuthFlow(agentServerUrl ?? undefined)
|
||||
return
|
||||
}
|
||||
|
||||
setTemplateValues({
|
||||
type: template.id,
|
||||
name: template.name,
|
||||
@@ -129,11 +233,18 @@ export const AISettingsPage: FC = () => {
|
||||
}
|
||||
|
||||
const confirmDeleteProvider = async () => {
|
||||
if (providerToDelete) {
|
||||
await deleteProvider(providerToDelete.id)
|
||||
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
|
||||
setProviderToDelete(null)
|
||||
if (!providerToDelete) return
|
||||
|
||||
// Clear OAuth tokens on server for OAuth-based providers
|
||||
const oauthFlow = oauthFlows[providerToDelete.type]
|
||||
if (oauthFlow) {
|
||||
await oauthFlow.disconnect()
|
||||
track(oauthFlow.disconnectedEvent)
|
||||
}
|
||||
|
||||
await deleteProvider(providerToDelete.id)
|
||||
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
|
||||
setProviderToDelete(null)
|
||||
}
|
||||
|
||||
const handleAddKeysToIncomplete = (provider: IncompleteProvider) => {
|
||||
|
||||
@@ -61,6 +61,9 @@ const providerTypeEnum = z.enum([
|
||||
'lmstudio',
|
||||
'bedrock',
|
||||
'browseros',
|
||||
'chatgpt-pro',
|
||||
'github-copilot',
|
||||
'qwen-code',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -84,6 +87,9 @@ export const providerFormSchema = z
|
||||
secretAccessKey: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
sessionToken: z.string().optional(),
|
||||
// ChatGPT Pro (Codex)
|
||||
reasoningEffort: z.enum(['none', 'low', 'medium', 'high']).optional(),
|
||||
reasoningSummary: z.enum(['auto', 'concise', 'detailed']).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Azure: require either resourceName or baseUrl
|
||||
@@ -127,6 +133,14 @@ export const providerFormSchema = z
|
||||
})
|
||||
}
|
||||
}
|
||||
// OAuth providers: no credentials needed (server-managed)
|
||||
else if (
|
||||
data.type === 'chatgpt-pro' ||
|
||||
data.type === 'github-copilot' ||
|
||||
data.type === 'qwen-code'
|
||||
) {
|
||||
// No validation needed — OAuth tokens are on the server
|
||||
}
|
||||
// Other providers: require baseUrl
|
||||
else if (!data.baseUrl) {
|
||||
ctx.addIssue({
|
||||
@@ -182,6 +196,11 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
const filteredProviderTypeOptions = providerTypeOptions.filter((opt) => {
|
||||
if (opt.value === 'chatgpt-pro')
|
||||
return supports(Feature.CHATGPT_PRO_SUPPORT)
|
||||
if (opt.value === 'github-copilot')
|
||||
return supports(Feature.GITHUB_COPILOT_SUPPORT)
|
||||
if (opt.value === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
|
||||
if (opt.value === 'moonshot')
|
||||
return kimiLaunch || initialValues?.type === 'moonshot'
|
||||
if (opt.value === 'openai-compatible') {
|
||||
@@ -209,6 +228,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
secretAccessKey: initialValues?.secretAccessKey || '',
|
||||
region: initialValues?.region || '',
|
||||
sessionToken: initialValues?.sessionToken || '',
|
||||
reasoningEffort: initialValues?.reasoningEffort || 'high',
|
||||
reasoningSummary: initialValues?.reasoningSummary || 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -301,6 +322,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
secretAccessKey: initialValues.secretAccessKey || '',
|
||||
region: initialValues.region || '',
|
||||
sessionToken: initialValues.sessionToken || '',
|
||||
reasoningEffort: initialValues.reasoningEffort || 'high',
|
||||
reasoningSummary: initialValues.reasoningSummary || 'auto',
|
||||
})
|
||||
setIsCustomModel(false)
|
||||
}
|
||||
@@ -326,6 +349,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
secretAccessKey: '',
|
||||
region: '',
|
||||
sessionToken: '',
|
||||
reasoningEffort: 'high',
|
||||
reasoningSummary: 'auto',
|
||||
})
|
||||
setIsCustomModel(false)
|
||||
}
|
||||
@@ -363,6 +388,14 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
const canTest = (): boolean => {
|
||||
if (!watchedModelId) return false
|
||||
|
||||
// OAuth providers: always testable (server has the OAuth token)
|
||||
if (
|
||||
watchedType === 'chatgpt-pro' ||
|
||||
watchedType === 'github-copilot' ||
|
||||
watchedType === 'qwen-code'
|
||||
)
|
||||
return true
|
||||
|
||||
if (watchedType === 'azure') {
|
||||
return !!(watchedResourceName || watchedBaseUrl) && !!watchedApiKey
|
||||
}
|
||||
@@ -444,6 +477,85 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
}
|
||||
|
||||
const renderProviderSpecificFields = () => {
|
||||
// OAuth-only providers (no API key needed)
|
||||
if (watchedType === 'github-copilot' || watchedType === 'qwen-code') {
|
||||
const name = watchedType === 'github-copilot' ? 'GitHub' : 'Qwen Code'
|
||||
return (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 text-sm dark:border-green-800 dark:bg-green-950 dark:text-green-300">
|
||||
Credentials are managed via {name} OAuth. No API key needed.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// ChatGPT Pro: OAuth credentials + Codex reasoning settings
|
||||
if (watchedType === 'chatgpt-pro') {
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 text-sm dark:border-green-800 dark:bg-green-950 dark:text-green-300">
|
||||
Credentials are managed via OAuth. No API key needed.
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reasoningEffort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reasoning Effort</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || 'high'}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
How much the model thinks before responding
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reasoningSummary"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reasoning Summary</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || 'auto'}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="concise">Concise</SelectItem>
|
||||
<SelectItem value="detailed">Detailed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Detail level of visible thinking steps
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (watchedType === 'azure') {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -103,8 +103,10 @@ export const ProviderCard: FC<ProviderCardProps> = ({
|
||||
for better performance.
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
) : provider.baseUrl ? (
|
||||
`${provider.modelId} • ${provider.baseUrl}`
|
||||
) : (
|
||||
provider.modelId
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,11 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
|
||||
const kimiLaunch = useKimiLaunch()
|
||||
|
||||
const filteredTemplates = providerTemplates.filter((template) => {
|
||||
if (template.id === 'chatgpt-pro')
|
||||
return supports(Feature.CHATGPT_PRO_SUPPORT)
|
||||
if (template.id === 'github-copilot')
|
||||
return supports(Feature.GITHUB_COPILOT_SUPPORT)
|
||||
if (template.id === 'qwen-code') return supports(Feature.QWEN_CODE_SUPPORT)
|
||||
if (template.id === 'moonshot') return kimiLaunch
|
||||
if (template.id === 'openai-compatible') {
|
||||
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface ModelsData {
|
||||
bedrock: ModelInfo[]
|
||||
browseros: ModelInfo[]
|
||||
moonshot: ModelInfo[]
|
||||
'chatgpt-pro': ModelInfo[]
|
||||
'github-copilot': ModelInfo[]
|
||||
'qwen-code': ModelInfo[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +93,48 @@ export const MODELS_DATA: ModelsData = {
|
||||
],
|
||||
bedrock: [],
|
||||
browseros: [{ modelId: 'browseros-auto', contextLength: 200000 }],
|
||||
'chatgpt-pro': [
|
||||
{ modelId: 'gpt-5.4', contextLength: 400000 },
|
||||
{ modelId: 'gpt-5.3-codex', contextLength: 400000 },
|
||||
{ modelId: 'gpt-5.2-codex', contextLength: 400000 },
|
||||
{ modelId: 'gpt-5.2', contextLength: 200000 },
|
||||
{ modelId: 'gpt-5.1-codex', contextLength: 400000 },
|
||||
{ modelId: 'gpt-5.1-codex-max', contextLength: 400000 },
|
||||
{ modelId: 'gpt-5.1-codex-mini', contextLength: 400000 },
|
||||
{ modelId: 'gpt-5.1', contextLength: 200000 },
|
||||
],
|
||||
'github-copilot': [
|
||||
// Free tier (unlimited with Pro)
|
||||
{ modelId: 'gpt-5-mini', contextLength: 128000 },
|
||||
{ modelId: 'claude-haiku-4.5', contextLength: 128000 },
|
||||
{ modelId: 'gpt-4o', contextLength: 64000 },
|
||||
{ modelId: 'gpt-4.1', contextLength: 64000 },
|
||||
// Premium models (Pro: 300/mo, Pro+: 1500/mo)
|
||||
{ modelId: 'claude-sonnet-4.6', contextLength: 128000 },
|
||||
{ modelId: 'claude-sonnet-4.5', contextLength: 128000 },
|
||||
{ modelId: 'claude-sonnet-4', contextLength: 128000 },
|
||||
{ modelId: 'claude-opus-4.6', contextLength: 128000 },
|
||||
{ modelId: 'claude-opus-4.5', contextLength: 128000 },
|
||||
{ modelId: 'gemini-2.5-pro', contextLength: 128000 },
|
||||
{ modelId: 'gemini-3-pro-preview', contextLength: 128000 },
|
||||
{ modelId: 'gemini-3-flash-preview', contextLength: 128000 },
|
||||
{ modelId: 'gemini-3.1-pro-preview', contextLength: 128000 },
|
||||
{ modelId: 'gpt-5.4', contextLength: 272000 },
|
||||
{ modelId: 'gpt-5.4-mini', contextLength: 128000 },
|
||||
{ modelId: 'gpt-5.3-codex', contextLength: 272000 },
|
||||
{ modelId: 'gpt-5.2-codex', contextLength: 272000 },
|
||||
{ modelId: 'gpt-5.2', contextLength: 128000 },
|
||||
{ modelId: 'gpt-5.1-codex', contextLength: 128000 },
|
||||
{ modelId: 'gpt-5.1-codex-max', contextLength: 128000 },
|
||||
{ modelId: 'gpt-5.1', contextLength: 128000 },
|
||||
{ modelId: 'grok-code-fast-1', contextLength: 128000 },
|
||||
],
|
||||
'qwen-code': [
|
||||
{ modelId: 'coder-model', contextLength: 1000000 },
|
||||
{ modelId: 'qwen3-coder-plus', contextLength: 1000000 },
|
||||
{ modelId: 'qwen3-coder-flash', contextLength: 1000000 },
|
||||
{ modelId: 'qwen3.5-plus', contextLength: 1000000 },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ export const useGetUserMCPIntegrations = () => {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [INTEGRATIONS_QUERY_KEY, agentServerUrl],
|
||||
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled
|
||||
queryFn: () => getUserMCPIntegrations(agentServerUrl!),
|
||||
enabled: !!agentServerUrl,
|
||||
refetchOnWindowFocus: true,
|
||||
|
||||
@@ -17,11 +17,8 @@ export const SettingsSidebarLayout: FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
track(SETTINGS_PAGE_VIEWED_EVENT, { page: location.pathname })
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
}, [])
|
||||
}, [location.pathname])
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
|
||||
@@ -7,8 +7,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import { ShortcutsDialog } from '@/entrypoints/newtab/index/ShortcutsDialog'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { SETTINGS_PAGE_VIEWED_EVENT } from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { RpcClientProvider } from '@/lib/rpc/RpcClientProvider'
|
||||
|
||||
const COLLAPSE_DELAY = 150
|
||||
@@ -25,10 +23,6 @@ export const SidebarLayout: FC = () => {
|
||||
setShortcutsDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
track(SETTINGS_PAGE_VIEWED_EVENT, { page: location.pathname })
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
}, [])
|
||||
@@ -103,11 +97,17 @@ export const SidebarLayout: FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Main content - full width, centered */}
|
||||
<main className="min-h-screen overflow-y-auto">
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{location.pathname === '/home/chat' ? (
|
||||
<main className="relative h-dvh overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
) : (
|
||||
<main className="min-h-screen overflow-y-auto">
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
<ShortcutsDialog
|
||||
open={shortcutsDialogOpen}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChevronDown, Loader2, Sparkles, Undo2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod/v3'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
@@ -34,16 +35,15 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SCHEDULED_TASK_PROMPT_REFINED_EVENT } from '@/lib/constants/analyticsEvents'
|
||||
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 { 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
|
||||
@@ -117,6 +117,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
const [isRefining, setIsRefining] = useState(false)
|
||||
const originalPromptRef = useRef<string | null>(null)
|
||||
const refineRequestIdRef = useRef(0)
|
||||
const isProgrammaticChange = useRef(false)
|
||||
|
||||
// Load providers from storage
|
||||
useEffect(() => {
|
||||
@@ -179,6 +180,24 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
type: p.type,
|
||||
}))
|
||||
|
||||
// Replace textarea content via execCommand so the browser's native undo
|
||||
// stack (Cmd+Z / Ctrl+Z) records the change. Falls back to form.setValue
|
||||
// if the textarea element can't be found.
|
||||
const setQueryWithUndo = (value: string) => {
|
||||
const textarea = document.querySelector(
|
||||
'textarea[name="query"]',
|
||||
) as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
isProgrammaticChange.current = true
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
document.execCommand('insertText', false, value)
|
||||
isProgrammaticChange.current = false
|
||||
} else {
|
||||
form.setValue('query', value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefinePrompt = async () => {
|
||||
const currentQuery = form.getValues('query').trim()
|
||||
const currentName = form.getValues('name').trim()
|
||||
@@ -195,7 +214,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
providerId: form.getValues('providerId'),
|
||||
})
|
||||
if (requestId !== refineRequestIdRef.current) return
|
||||
form.setValue('query', refined)
|
||||
setQueryWithUndo(refined)
|
||||
track(SCHEDULED_TASK_PROMPT_REFINED_EVENT)
|
||||
} catch {
|
||||
if (requestId !== refineRequestIdRef.current) return
|
||||
@@ -210,7 +229,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
|
||||
const handleUndoRefine = () => {
|
||||
if (originalPromptRef.current !== null) {
|
||||
form.setValue('query', originalPromptRef.current)
|
||||
setQueryWithUndo(originalPromptRef.current)
|
||||
originalPromptRef.current = null
|
||||
}
|
||||
}
|
||||
@@ -272,7 +291,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 px-2 py-1 text-xs text-muted-foreground"
|
||||
className="h-auto gap-1 px-2 py-1 text-muted-foreground text-xs"
|
||||
disabled={!queryValue?.trim() || isRefining}
|
||||
onClick={handleRefinePrompt}
|
||||
>
|
||||
@@ -291,7 +310,10 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e)
|
||||
if (originalPromptRef.current !== null) {
|
||||
if (
|
||||
!isProgrammaticChange.current &&
|
||||
originalPromptRef.current !== null
|
||||
) {
|
||||
originalPromptRef.current = null
|
||||
}
|
||||
}}
|
||||
@@ -300,7 +322,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
{!isRefining && originalPromptRef.current !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
className="flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground"
|
||||
onClick={handleUndoRefine}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, Pencil, Plus, Trash2, Wand2 } from 'lucide-react'
|
||||
import { AlertCircle, Eye, Pencil, Plus, Trash2, Wand2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -108,23 +109,19 @@ export const SkillsPage: FC = () => {
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && skills.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onEdit={() => handleEdit(skill)}
|
||||
onDelete={() => setSkillToDelete(skill)}
|
||||
onToggle={(enabled) => handleToggle(skill, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SkillSections
|
||||
skills={skills}
|
||||
onEdit={handleEdit}
|
||||
onDelete={(skill) => setSkillToDelete(skill)}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SkillDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
editingSkill={editingSkill}
|
||||
readOnly={editingSkill?.builtIn}
|
||||
onSave={async (data) => {
|
||||
try {
|
||||
if (editingSkill) {
|
||||
@@ -251,6 +248,50 @@ const EmptyState: FC<{ onCreateClick: () => void }> = ({ onCreateClick }) => (
|
||||
</Card>
|
||||
)
|
||||
|
||||
const SkillGrid: FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const SkillSections: FC<{
|
||||
skills: SkillMeta[]
|
||||
onEdit: (skill: SkillMeta) => void
|
||||
onDelete: (skill: SkillMeta) => void
|
||||
onToggle: (skill: SkillMeta, enabled: boolean) => void
|
||||
}> = ({ skills, onEdit, onDelete, onToggle }) => {
|
||||
const userSkills = skills.filter((s) => !s.builtIn)
|
||||
const builtInSkills = skills.filter((s) => s.builtIn)
|
||||
|
||||
const renderCard = (skill: SkillMeta) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onEdit={() => onEdit(skill)}
|
||||
onDelete={() => onDelete(skill)}
|
||||
onToggle={(enabled) => onToggle(skill, enabled)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{userSkills.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-sm">My Skills</h3>
|
||||
<SkillGrid>{userSkills.map(renderCard)}</SkillGrid>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{builtInSkills.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-sm">BrowserOS Skills</h3>
|
||||
<SkillGrid>{builtInSkills.map(renderCard)}</SkillGrid>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SkillCard: FC<{
|
||||
skill: SkillMeta
|
||||
onEdit: () => void
|
||||
@@ -260,7 +301,14 @@ const SkillCard: FC<{
|
||||
<Card className="h-full py-0 shadow-sm">
|
||||
<CardContent className="flex h-full flex-col p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="font-semibold text-sm leading-5">{skill.name}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-sm leading-5">{skill.name}</h2>
|
||||
{skill.builtIn ? (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||
Built-in
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
@@ -281,18 +329,29 @@ const SkillCard: FC<{
|
||||
onClick={onEdit}
|
||||
className="-ml-2 h-7 px-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="size-7 text-muted-foreground hover:bg-transparent hover:text-destructive"
|
||||
aria-label={`Delete ${skill.name}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{skill.builtIn ? (
|
||||
<>
|
||||
<Eye className="size-3.5" />
|
||||
View
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{!skill.builtIn ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="size-7 text-muted-foreground hover:bg-transparent hover:text-destructive"
|
||||
aria-label={`Delete ${skill.name}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -302,12 +361,13 @@ const SkillDialog: FC<{
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editingSkill: SkillDetail | null
|
||||
readOnly?: boolean
|
||||
onSave: (data: {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
}) => Promise<void>
|
||||
}> = ({ open, onOpenChange, editingSkill, onSave }) => {
|
||||
}> = ({ open, onOpenChange, editingSkill, readOnly, onSave }) => {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
@@ -354,12 +414,18 @@ const SkillDialog: FC<{
|
||||
<DialogContent className="flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl">
|
||||
<DialogHeader className="border-b px-6 py-5">
|
||||
<DialogTitle>
|
||||
{editingSkill ? 'Edit Skill' : 'Create Skill'}
|
||||
{readOnly
|
||||
? 'View Skill'
|
||||
: editingSkill
|
||||
? 'Edit Skill'
|
||||
: 'Create Skill'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingSkill
|
||||
? 'Refine when the agent should use this skill and how it should execute it.'
|
||||
: 'Define a reusable instruction set your agent can apply when a request matches.'}
|
||||
{readOnly
|
||||
? 'This skill is managed by BrowserOS and updated automatically.'
|
||||
: editingSkill
|
||||
? 'Refine when the agent should use this skill and how it should execute it.'
|
||||
: 'Define a reusable instruction set your agent can apply when a request matches.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -373,6 +439,7 @@ const SkillDialog: FC<{
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
maxLength={100}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs leading-5">
|
||||
Keep it short and recognizable in the skills list.
|
||||
@@ -388,19 +455,22 @@ const SkillDialog: FC<{
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
maxLength={500}
|
||||
className="min-h-28 resize-none bg-background"
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs leading-5">
|
||||
This is the trigger summary the agent uses to pick the skill.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto rounded-lg border border-border/60 border-dashed bg-muted/30 px-3 py-2.5">
|
||||
<p className="font-medium text-muted-foreground text-xs">Tip</p>
|
||||
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-muted-foreground text-xs leading-5">
|
||||
<li>List the ordered steps the agent should follow.</li>
|
||||
<li>Close with the output or formatting you expect back.</li>
|
||||
</ul>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<div className="mt-auto rounded-lg border border-border/60 border-dashed bg-muted/30 px-3 py-2.5">
|
||||
<p className="font-medium text-muted-foreground text-xs">Tip</p>
|
||||
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-muted-foreground text-xs leading-5">
|
||||
<li>List the ordered steps the agent should follow.</li>
|
||||
<li>Close with the output or formatting you expect back.</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col px-6 py-5">
|
||||
@@ -411,36 +481,52 @@ const SkillDialog: FC<{
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<MarkdownEditor
|
||||
id="skill-content"
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
onKeyDown={handleContentKeyDown}
|
||||
placeholder="Write instructions for the agent. Use markdown for structure."
|
||||
className="mt-4 min-h-[320px] flex-1 overflow-y-auto text-sm"
|
||||
/>
|
||||
{readOnly ? (
|
||||
<div className="prose prose-sm dark:prose-invert mt-4 min-h-[320px] max-w-none flex-1 overflow-y-auto rounded-md border p-4 text-sm">
|
||||
<Markdown>{content}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
id="skill-content"
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
onKeyDown={handleContentKeyDown}
|
||||
placeholder="Write instructions for the agent. Use markdown for structure."
|
||||
className="mt-4 min-h-[320px] flex-1 overflow-y-auto text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Saved locally and available to your agent immediately.
|
||||
{readOnly
|
||||
? 'This skill is managed by BrowserOS and updated automatically.'
|
||||
: 'Saved locally and available to your agent immediately.'}
|
||||
</p>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || saving}>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: editingSkill
|
||||
? 'Update Skill'
|
||||
: 'Create Skill'}
|
||||
</Button>
|
||||
{readOnly ? (
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || saving}>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: editingSkill
|
||||
? 'Update Skill'
|
||||
: 'Create Skill'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SkillMeta = {
|
||||
description: string
|
||||
location: string
|
||||
enabled: boolean
|
||||
builtIn: boolean
|
||||
}
|
||||
|
||||
export type SkillDetail = SkillMeta & {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { AlertCircle, Clock, Coins, CreditCard, Zap } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getCreditBarColor,
|
||||
getCreditTextColor,
|
||||
} from '@/lib/credits/credit-colors'
|
||||
import { useCredits } from '@/lib/credits/useCredits'
|
||||
import { BrowserOSIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const UsagePage: FC = () => {
|
||||
const { data, isLoading, error } = useCredits()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground text-sm">
|
||||
Loading usage data...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center gap-4 rounded-xl border p-5">
|
||||
<BrowserOSIcon size={40} />
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Usage & Billing</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Monitor your BrowserOS AI credit usage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-8">
|
||||
<AlertCircle className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Unable to load credit information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const credits = data?.credits ?? 0
|
||||
const total = data?.dailyLimit ?? 100
|
||||
const percentage = Math.min((credits / total) * 100, 100)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center gap-4 rounded-xl border p-5">
|
||||
<BrowserOSIcon size={40} />
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Usage & Billing</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Monitor your BrowserOS AI credit usage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-semibold text-sm">Daily Credits</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn('font-bold text-2xl', getCreditTextColor(credits))}
|
||||
>
|
||||
{credits}
|
||||
<span className="ml-1 font-normal text-muted-foreground text-sm">
|
||||
/ {total}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 h-2.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
getCreditBarColor(credits),
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
|
||||
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-xs">Resets daily</p>
|
||||
<p className="text-muted-foreground text-xs">Midnight UTC</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2.5">
|
||||
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium text-xs">Credits used today</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{total - credits} of {total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-semibold text-sm">Need more credits?</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Additional credit packages coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled className="opacity-50">
|
||||
Add Credits
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
syncScheduledJobs,
|
||||
} from '@/lib/schedules/scheduleStorage'
|
||||
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
|
||||
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
|
||||
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
|
||||
import { scheduledJobRuns } from './scheduledJobRuns'
|
||||
|
||||
@@ -66,7 +67,12 @@ export default defineBackground(() => {
|
||||
}
|
||||
})
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message?.type === 'get-tab-id') {
|
||||
sendResponse({ tabId: sender.tab?.id })
|
||||
return true
|
||||
}
|
||||
|
||||
if (message?.type === 'AUTH_SUCCESS' && sender.tab?.id) {
|
||||
const tabId = sender.tab.id
|
||||
authRedirectPathStorage
|
||||
@@ -93,6 +99,17 @@ export default defineBackground(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up selected text storage when a tab is closed
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
const key = String(tabId)
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
if (map[key]) {
|
||||
const { [key]: _, ...rest } = map
|
||||
selectedTextStorage.setValue(rest)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
sessionStorage.watch(async (newSession) => {
|
||||
if (newSession?.user?.id) {
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import { AppSelector } from '@/components/elements/AppSelector'
|
||||
import {
|
||||
GlowingBorder,
|
||||
@@ -36,7 +38,6 @@ import {
|
||||
import {
|
||||
NEWTAB_AI_TRIGGERED_EVENT,
|
||||
NEWTAB_APPS_OPENED_EVENT,
|
||||
NEWTAB_CHAT_RESET_EVENT,
|
||||
NEWTAB_CHAT_STARTED_EVENT,
|
||||
NEWTAB_OPENED_EVENT,
|
||||
NEWTAB_SEARCH_EXECUTED_EVENT,
|
||||
@@ -45,6 +46,8 @@ import {
|
||||
NEWTAB_TABS_OPENED_EVENT,
|
||||
NEWTAB_WORKSPACE_OPENED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
|
||||
import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
|
||||
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
|
||||
@@ -58,7 +61,6 @@ import {
|
||||
useSuggestions,
|
||||
} from './lib/suggestions/useSuggestions'
|
||||
import { NewTabBranding } from './NewTabBranding'
|
||||
import { NewTabChat } from './NewTabChat'
|
||||
import { NewTabTip } from './NewTabTip'
|
||||
import { ScheduleResults } from './ScheduleResults'
|
||||
import { SearchSuggestions } from './SearchSuggestions'
|
||||
@@ -78,13 +80,13 @@ interface MentionState {
|
||||
*/
|
||||
export const NewTab = () => {
|
||||
const activeHint = useActiveHint()
|
||||
const navigate = useNavigate()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const tabsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [shortcutsDialogOpen, setShortcutsDialogOpen] = useState(false)
|
||||
const [chatActive, setChatActive] = useState(false)
|
||||
const [mentionState, setMentionState] = useState<MentionState>({
|
||||
isOpen: false,
|
||||
filterText: '',
|
||||
@@ -92,13 +94,12 @@ export const NewTab = () => {
|
||||
})
|
||||
const { selectedFolder } = useWorkspace()
|
||||
const { supports } = useCapabilities()
|
||||
const { providers, selectedProvider, handleSelectProvider } =
|
||||
useChatSessionContext()
|
||||
const { servers: mcpServers } = useMcpServers()
|
||||
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
|
||||
useSyncRemoteIntegrations()
|
||||
|
||||
const { messages, sendMessage, setMode, resetConversation } =
|
||||
useChatSessionContext()
|
||||
|
||||
const connectedManagedServers = mcpServers.filter((s) => {
|
||||
if (s.type !== 'managed' || !s.managedServerName) return false
|
||||
return userMCPIntegrations?.integrations?.find(
|
||||
@@ -275,17 +276,28 @@ export const NewTab = () => {
|
||||
|
||||
const startInlineChat = (
|
||||
message: string,
|
||||
mode: 'chat' | 'agent',
|
||||
action?: ReturnType<
|
||||
typeof createBrowserOSAction | typeof createAITabAction
|
||||
>,
|
||||
chatMode: 'chat' | 'agent',
|
||||
aiTab?: { name: string; description: string },
|
||||
) => {
|
||||
track(NEWTAB_CHAT_STARTED_EVENT, { mode, tabs_count: selectedTabs.length })
|
||||
setMode(mode)
|
||||
setChatActive(true)
|
||||
sendMessage({ text: message, action })
|
||||
track(NEWTAB_CHAT_STARTED_EVENT, {
|
||||
mode: chatMode,
|
||||
tabs_count: selectedTabs.length,
|
||||
})
|
||||
const tabIds = selectedTabs
|
||||
.map((t) => t.id)
|
||||
.filter((id): id is number => id !== undefined)
|
||||
reset()
|
||||
setSelectedTabs([])
|
||||
const params = new URLSearchParams({ q: message, mode: chatMode })
|
||||
if (tabIds.length > 0) {
|
||||
params.set('tabs', tabIds.join(','))
|
||||
}
|
||||
if (aiTab) {
|
||||
params.set('actionType', 'ai-tab')
|
||||
params.set('tabName', aiTab.name)
|
||||
params.set('tabDescription', aiTab.description)
|
||||
}
|
||||
navigate(`/home/chat?${params.toString()}`)
|
||||
}
|
||||
|
||||
const runSelectedAction = (item: SuggestionItem | undefined) => {
|
||||
@@ -306,15 +318,18 @@ export const NewTab = () => {
|
||||
mode: 'agent',
|
||||
tabs_count: selectedTabs.length,
|
||||
})
|
||||
const action = createAITabAction({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
const searchQuery = `${item.name}${item.description ? ` - ${item.description}` : ''}}`
|
||||
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
|
||||
startInlineChat(searchQuery, 'agent', action)
|
||||
startInlineChat(searchQuery, 'agent', {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
})
|
||||
} else {
|
||||
const action = createAITabAction({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
openSidePanelWithSearch('open', {
|
||||
query: searchQuery,
|
||||
mode: 'agent',
|
||||
@@ -330,14 +345,14 @@ export const NewTab = () => {
|
||||
mode: item.mode,
|
||||
tabs_count: selectedTabs.length,
|
||||
})
|
||||
const action = createBrowserOSAction({
|
||||
mode: item.mode,
|
||||
message: item.message,
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
|
||||
startInlineChat(item.message, item.mode, action)
|
||||
startInlineChat(item.message, item.mode)
|
||||
} else {
|
||||
const action = createBrowserOSAction({
|
||||
mode: item.mode,
|
||||
message: item.message,
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
openSidePanelWithSearch('open', {
|
||||
query: item.message,
|
||||
mode: item.mode,
|
||||
@@ -351,12 +366,6 @@ export const NewTab = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackToSearch = () => {
|
||||
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
|
||||
resetConversation()
|
||||
setChatActive(false)
|
||||
}
|
||||
|
||||
const isSuggestionsVisible =
|
||||
!mentionState.isOpen &&
|
||||
((isOpen && inputValue.length) ||
|
||||
@@ -368,10 +377,6 @@ export const NewTab = () => {
|
||||
track(NEWTAB_OPENED_EVENT)
|
||||
}, [])
|
||||
|
||||
if (chatActive) {
|
||||
return <NewTabChat onBackToSearch={handleBackToSearch} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-[max(25vh,16px)]">
|
||||
{/* Main content */}
|
||||
@@ -524,6 +529,34 @@ export const NewTab = () => {
|
||||
{mounted && (
|
||||
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedProvider && (
|
||||
<ChatProviderSelector
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={selectedProvider.name}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-lg transition-all',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={16} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
type={selectedProvider.type as ProviderType}
|
||||
size={16}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</ChatProviderSelector>
|
||||
)}
|
||||
|
||||
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) && (
|
||||
<WorkspaceSelector>
|
||||
<Button
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ChatEmptyState } from '@/entrypoints/sidepanel/index/ChatEmptyState'
|
||||
import { ChatError } from '@/entrypoints/sidepanel/index/ChatError'
|
||||
import { ChatFooter } from '@/entrypoints/sidepanel/index/ChatFooter'
|
||||
import { ChatHeader } from '@/entrypoints/sidepanel/index/ChatHeader'
|
||||
import { ChatMessages } from '@/entrypoints/sidepanel/index/ChatMessages'
|
||||
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
|
||||
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
|
||||
import { createBrowserOSAction } from '@/lib/chat-actions/types'
|
||||
import {
|
||||
createAITabAction,
|
||||
createBrowserOSAction,
|
||||
} from '@/lib/chat-actions/types'
|
||||
import { useChatActions } from '@/lib/chat-actions/useChatActions'
|
||||
import {
|
||||
NEWTAB_AI_TRIGGERED_EVENT,
|
||||
NEWTAB_CHAT_MODE_CHANGED_EVENT,
|
||||
NEWTAB_CHAT_RESET_EVENT,
|
||||
NEWTAB_CHAT_STOPPED_EVENT,
|
||||
NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT,
|
||||
NEWTAB_TAB_REMOVED_EVENT,
|
||||
NEWTAB_TAB_TOGGLED_EVENT,
|
||||
NEWTAB_VOICE_ERROR_EVENT,
|
||||
NEWTAB_VOICE_RECORDING_STARTED_EVENT,
|
||||
NEWTAB_VOICE_RECORDING_STOPPED_EVENT,
|
||||
NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { NewTabChatHeader } from './NewTabChatHeader'
|
||||
|
||||
interface NewTabChatProps {
|
||||
onBackToSearch: () => void
|
||||
}
|
||||
export const NewTabChat: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const hasSentInitialRef = useRef(false)
|
||||
|
||||
export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
const {
|
||||
mode,
|
||||
setMode,
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
agentUrlError,
|
||||
chatError,
|
||||
getActionForMessage,
|
||||
@@ -42,71 +48,80 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
selectedProvider,
|
||||
handleSelectProvider,
|
||||
resetConversation,
|
||||
} = useChatSessionContext()
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [mounted, setMounted] = useState(false)
|
||||
input,
|
||||
setInput,
|
||||
attachedTabs,
|
||||
mounted,
|
||||
voiceState,
|
||||
handleModeChange,
|
||||
handleStop,
|
||||
toggleTabSelection,
|
||||
removeTab,
|
||||
handleSubmit,
|
||||
handleSuggestionClick,
|
||||
} = useChatActions({
|
||||
events: {
|
||||
modeChanged: NEWTAB_CHAT_MODE_CHANGED_EVENT,
|
||||
stopClicked: NEWTAB_CHAT_STOPPED_EVENT,
|
||||
suggestionClicked: NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT,
|
||||
tabToggled: NEWTAB_TAB_TOGGLED_EVENT,
|
||||
tabRemoved: NEWTAB_TAB_REMOVED_EVENT,
|
||||
aiTriggered: NEWTAB_AI_TRIGGERED_EVENT,
|
||||
voiceRecordingStarted: NEWTAB_VOICE_RECORDING_STARTED_EVENT,
|
||||
voiceRecordingStopped: NEWTAB_VOICE_RECORDING_STOPPED_EVENT,
|
||||
voiceTranscriptionCompleted: NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
|
||||
voiceError: NEWTAB_VOICE_ERROR_EVENT,
|
||||
},
|
||||
})
|
||||
|
||||
// Send the initial message from URL query params (from /home search bar).
|
||||
// Guarded by ref to prevent double-fire in React Strict Mode.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: must only run once on mount
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
if (hasSentInitialRef.current) return
|
||||
const query = searchParams.get('q')
|
||||
const chatMode = searchParams.get('mode')
|
||||
const tabIdsParam = searchParams.get('tabs')
|
||||
if (!query) return
|
||||
|
||||
const handleModeChange = (newMode: ChatMode) => {
|
||||
track(NEWTAB_CHAT_MODE_CHANGED_EVENT, { from: mode, to: newMode })
|
||||
setMode(newMode)
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
track(NEWTAB_CHAT_STOPPED_EVENT)
|
||||
stop()
|
||||
}
|
||||
|
||||
const toggleTabSelection = (tab: chrome.tabs.Tab) => {
|
||||
setAttachedTabs((prev) => {
|
||||
const isSelected = prev.some((t) => t.id === tab.id)
|
||||
track(NEWTAB_TAB_TOGGLED_EVENT, {
|
||||
action: isSelected ? 'removed' : 'added',
|
||||
})
|
||||
if (isSelected) {
|
||||
return prev.filter((t) => t.id !== tab.id)
|
||||
}
|
||||
return [...prev, tab]
|
||||
})
|
||||
}
|
||||
|
||||
const removeTab = (tabId?: number) => {
|
||||
track(NEWTAB_TAB_REMOVED_EVENT)
|
||||
setAttachedTabs((prev) => prev.filter((t) => t.id !== tabId))
|
||||
}
|
||||
|
||||
const executeMessage = (customMessageText?: string) => {
|
||||
const messageText = customMessageText ? customMessageText : input.trim()
|
||||
if (!messageText) return
|
||||
|
||||
if (attachedTabs.length) {
|
||||
const action = createBrowserOSAction({
|
||||
mode,
|
||||
message: messageText,
|
||||
tabs: attachedTabs,
|
||||
})
|
||||
sendMessage({ text: messageText, action })
|
||||
} else {
|
||||
sendMessage({ text: messageText })
|
||||
hasSentInitialRef.current = true
|
||||
if (chatMode === 'chat' || chatMode === 'agent') {
|
||||
setMode(chatMode)
|
||||
}
|
||||
setInput('')
|
||||
setAttachedTabs([])
|
||||
}
|
||||
setSearchParams({}, { replace: true })
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
executeMessage()
|
||||
}
|
||||
const actionType = searchParams.get('actionType')
|
||||
const tabName = searchParams.get('tabName')
|
||||
const tabDescription = searchParams.get('tabDescription')
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
track(NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT, { mode })
|
||||
executeMessage(suggestion)
|
||||
}
|
||||
if (tabIdsParam) {
|
||||
const tabIds = tabIdsParam.split(',').map(Number).filter(Boolean)
|
||||
chrome.tabs.query({}).then((allTabs) => {
|
||||
const matchedTabs = allTabs.filter(
|
||||
(t) => t.id !== undefined && tabIds.includes(t.id),
|
||||
)
|
||||
if (matchedTabs.length > 0) {
|
||||
const action =
|
||||
actionType === 'ai-tab' && tabName
|
||||
? createAITabAction({
|
||||
name: tabName,
|
||||
description: tabDescription ?? '',
|
||||
tabs: matchedTabs,
|
||||
})
|
||||
: createBrowserOSAction({
|
||||
mode: (chatMode as 'chat' | 'agent') ?? 'agent',
|
||||
message: query,
|
||||
tabs: matchedTabs,
|
||||
})
|
||||
sendMessage({ text: query, action })
|
||||
} else {
|
||||
sendMessage({ text: query })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
sendMessage({ text: query })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleNewConversation = () => {
|
||||
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
|
||||
@@ -116,17 +131,19 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
if (!selectedProvider) return null
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-2rem)] flex-col">
|
||||
<NewTabChatHeader
|
||||
selectedProvider={selectedProvider}
|
||||
providers={providers}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
onNewConversation={handleNewConversation}
|
||||
onBackToSearch={onBackToSearch}
|
||||
hasMessages={messages.length > 0}
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col overflow-hidden">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<ChatHeader
|
||||
selectedProvider={selectedProvider}
|
||||
providers={providers}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
onNewConversation={handleNewConversation}
|
||||
hasMessages={messages.length > 0}
|
||||
hideHistory
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col space-y-4 overflow-y-auto px-4 pt-4">
|
||||
<main className="styled-scrollbar [&_[data-streamdown='code-block']]:!max-w-full [&_[data-streamdown='code-block']]:!w-auto [&_[data-streamdown='table-wrapper']]:!max-w-full [&_[data-streamdown='table-wrapper']]:!w-auto mx-auto flex min-h-0 w-full max-w-3xl flex-1 flex-col space-y-4 overflow-y-auto overflow-x-hidden px-4 pt-4 [&_[data-streamdown='code-block']]:overflow-x-auto [&_[data-streamdown='table-wrapper']]:overflow-x-auto">
|
||||
{isRestoringConversation ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
@@ -156,7 +173,7 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
{chatError && <ChatError error={chatError} />}
|
||||
</main>
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl px-4">
|
||||
<div className="mx-auto w-full max-w-3xl flex-shrink-0 px-4 pb-2">
|
||||
<ChatFooter
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
@@ -168,6 +185,7 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
attachedTabs={attachedTabs}
|
||||
onToggleTab={toggleTabSelection}
|
||||
onRemoveTab={removeTab}
|
||||
voice={voiceState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { ArrowLeft, Plus } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
interface NewTabChatHeaderProps {
|
||||
selectedProvider: Provider
|
||||
providers: Provider[]
|
||||
onSelectProvider: (provider: Provider) => void
|
||||
onNewConversation: () => void
|
||||
onBackToSearch: () => void
|
||||
hasMessages: boolean
|
||||
}
|
||||
|
||||
export const NewTabChatHeader: FC<NewTabChatHeaderProps> = ({
|
||||
selectedProvider,
|
||||
providers,
|
||||
onSelectProvider,
|
||||
onNewConversation,
|
||||
onBackToSearch,
|
||||
hasMessages,
|
||||
}) => {
|
||||
return (
|
||||
<header className="flex items-center justify-between border-border/40 border-b bg-background/80 px-4 py-2.5 backdrop-blur-md">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Back to search */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToSearch}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Back to search"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Provider selector */}
|
||||
<ChatProviderSelector
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelectProvider={onSelectProvider}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group relative inline-flex cursor-pointer items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
|
||||
title="Change AI Provider"
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={18} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
type={selectedProvider.type as ProviderType}
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold text-base">
|
||||
{selectedProvider.name}
|
||||
</span>
|
||||
</button>
|
||||
</ChatProviderSelector>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{hasMessages && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewConversation}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -3,14 +3,19 @@ import { Outlet, useLocation } from 'react-router'
|
||||
import { ChatSessionProvider } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
|
||||
import { NewTabFocusGrid } from './NewTabFocusGrid'
|
||||
|
||||
const HIDE_FOCUS_GRID_PATHS = new Set([
|
||||
'/home/soul',
|
||||
'/home/memory',
|
||||
'/home/skills',
|
||||
'/home/chat',
|
||||
])
|
||||
|
||||
export const NewTabLayout: FC = () => {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<ChatSessionProvider origin="newtab">
|
||||
{location.pathname !== '/home/soul' &&
|
||||
location.pathname !== '/home/memory' &&
|
||||
location.pathname !== '/home/skills' && <NewTabFocusGrid />}
|
||||
{!HIDE_FOCUS_GRID_PATHS.has(location.pathname) && <NewTabFocusGrid />}
|
||||
<Outlet />
|
||||
</ChatSessionProvider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
|
||||
|
||||
const MAX_SELECTED_TEXT_LENGTH = 5000
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['*://*/*'],
|
||||
runAt: 'document_idle',
|
||||
async main() {
|
||||
const response = await chrome.runtime.sendMessage({ type: 'get-tab-id' })
|
||||
const tabId: number | undefined = response?.tabId
|
||||
if (!tabId) return
|
||||
|
||||
const key = String(tabId)
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
const text = window.getSelection()?.toString().trim()
|
||||
|
||||
if (text && text.length > 0) {
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
selectedTextStorage.setValue({
|
||||
...map,
|
||||
[key]: {
|
||||
text: text.slice(0, MAX_SELECTED_TEXT_LENGTH),
|
||||
pageUrl: window.location.href,
|
||||
pageTitle: document.title,
|
||||
tabId,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// User clicked without selecting — clear this tab's entry only
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
if (map[key]) {
|
||||
const { [key]: _, ...rest } = map
|
||||
selectedTextStorage.setValue(rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -30,6 +30,7 @@ function parseErrorMessage(message: string): {
|
||||
text: string
|
||||
url?: string
|
||||
isRateLimit?: boolean
|
||||
isCreditsExhausted?: boolean
|
||||
isConnectionError?: boolean
|
||||
} {
|
||||
// Detect MCP server connection failures
|
||||
@@ -44,6 +45,19 @@ function parseErrorMessage(message: string): {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect credit exhaustion from gateway
|
||||
if (
|
||||
message.includes('CREDITS_EXHAUSTED') ||
|
||||
message.includes('Daily credits exhausted')
|
||||
) {
|
||||
return {
|
||||
text: 'Daily credits exhausted. Credits reset at midnight UTC.',
|
||||
url: '/app.html#/settings/usage',
|
||||
isRateLimit: true,
|
||||
isCreditsExhausted: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Detect BrowserOS rate limit (unique pattern, no provider uses this)
|
||||
if (message.includes('BrowserOS LLM daily limit reached')) {
|
||||
return {
|
||||
@@ -70,9 +84,8 @@ function parseErrorMessage(message: string): {
|
||||
}
|
||||
|
||||
export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
const { text, url, isRateLimit, isConnectionError } = parseErrorMessage(
|
||||
error.message,
|
||||
)
|
||||
const { text, url, isRateLimit, isCreditsExhausted, isConnectionError } =
|
||||
parseErrorMessage(error.message)
|
||||
|
||||
// --- Commented out for Kimi partnership launch (restore after) ---
|
||||
// const surveyUrl = useMemo(
|
||||
@@ -128,7 +141,17 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
</p>
|
||||
)}
|
||||
--- End commented out survey code --- */}
|
||||
{isRateLimit && (
|
||||
{isCreditsExhausted && url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View Usage & Billing
|
||||
</a>
|
||||
)}
|
||||
{isRateLimit && !isCreditsExhausted && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{/* biome-ignore lint/a11y/useValidAnchor: link with click tracking */}
|
||||
|
||||
@@ -8,12 +8,17 @@ import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetU
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
|
||||
import {
|
||||
type SelectedTextData,
|
||||
selectedTextStorage,
|
||||
} from '@/lib/selected-text/selectedTextStorage'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VoiceInputState } from '@/lib/voice/useVoiceInput'
|
||||
import { useWorkspace } from '@/lib/workspace/use-workspace'
|
||||
import { ChatAttachedTabs } from './ChatAttachedTabs'
|
||||
import { ChatInput, type ChatInputHandle } from './ChatInput'
|
||||
import { ChatModeToggle } from './ChatModeToggle'
|
||||
import { ChatSelectedText } from './ChatSelectedText'
|
||||
import type { ChatMode } from './chatTypes'
|
||||
|
||||
interface ChatFooterProps {
|
||||
@@ -48,6 +53,33 @@ export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
const { servers: mcpServers } = useMcpServers()
|
||||
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
|
||||
const chatInputRef = useRef<ChatInputHandle>(null)
|
||||
const [selectionMap, setSelectionMap] = useState<
|
||||
Record<string, SelectedTextData>
|
||||
>({})
|
||||
const [activeTabId, setActiveTabId] = useState<number | undefined>()
|
||||
|
||||
// Track active tab for tab-scoped selection display
|
||||
useEffect(() => {
|
||||
chrome.tabs
|
||||
.query({ active: true, currentWindow: true })
|
||||
.then((tabs) => setActiveTabId(tabs[0]?.id))
|
||||
const listener = (activeInfo: { tabId: number }) => {
|
||||
setActiveTabId(activeInfo.tabId)
|
||||
}
|
||||
chrome.tabs.onActivated.addListener(listener)
|
||||
return () => chrome.tabs.onActivated.removeListener(listener)
|
||||
}, [])
|
||||
|
||||
// Watch selected text storage (per-tab map)
|
||||
useEffect(() => {
|
||||
selectedTextStorage.getValue().then(setSelectionMap)
|
||||
const unwatch = selectedTextStorage.watch(setSelectionMap)
|
||||
return () => unwatch()
|
||||
}, [])
|
||||
|
||||
const visibleSelectedText = activeTabId
|
||||
? (selectionMap[String(activeTabId)] ?? null)
|
||||
: null
|
||||
const [isTabMentionOpen, setIsTabMentionOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,6 +113,19 @@ export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
return (
|
||||
<footer className="border-border/40 border-t bg-background/80 backdrop-blur-md">
|
||||
<ChatAttachedTabs tabs={attachedTabs} onRemoveTab={onRemoveTab} />
|
||||
{visibleSelectedText && (
|
||||
<ChatSelectedText
|
||||
selectedText={visibleSelectedText}
|
||||
onDismiss={() => {
|
||||
if (!activeTabId) return
|
||||
const key = String(activeTabId)
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
const { [key]: _, ...rest } = map
|
||||
selectedTextStorage.setValue(rest)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -3,17 +3,34 @@ import type { FC } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { CreditBadge } from '@/components/credits/CreditBadge'
|
||||
import { ThemeToggle } from '@/components/elements/theme-toggle'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import { productRepositoryUrl } from '@/lib/constants/productUrls'
|
||||
import { useCredits } from '@/lib/credits/useCredits'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
const CreditsBadgeWrapper: FC = () => {
|
||||
const { supports } = useCapabilities()
|
||||
const { data } = useCredits()
|
||||
if (!supports(Feature.CREDITS_SUPPORT) || data === undefined) return null
|
||||
return (
|
||||
<CreditBadge
|
||||
credits={data.credits}
|
||||
onClick={() => window.open('/app.html#/settings/usage', '_blank')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChatHeaderProps {
|
||||
selectedProvider: Provider
|
||||
providers: Provider[]
|
||||
onSelectProvider: (provider: Provider) => void
|
||||
onNewConversation: () => void
|
||||
hasMessages: boolean
|
||||
hideHistory?: boolean
|
||||
}
|
||||
|
||||
export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
@@ -22,6 +39,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
onSelectProvider,
|
||||
onNewConversation,
|
||||
hasMessages,
|
||||
hideHistory,
|
||||
}) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
@@ -59,6 +77,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
</span>
|
||||
</button>
|
||||
</ChatProviderSelector>
|
||||
{selectedProvider.type === 'browseros' && <CreditsBadgeWrapper />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -73,24 +92,25 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isHistoryPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewConversationFromHistory}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/history"
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Chat history"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
{!hideHistory &&
|
||||
(isHistoryPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewConversationFromHistory}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/history"
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Chat history"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<a
|
||||
href={productRepositoryUrl}
|
||||
|
||||
@@ -280,7 +280,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
|
||||
if (voice.isTranscribing) {
|
||||
return (
|
||||
<button type="button" disabled className="rounded-full p-2 text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="rounded-full p-2 text-muted-foreground"
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<span className="sr-only">Transcribing</span>
|
||||
</button>
|
||||
@@ -317,7 +321,9 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || voice?.isRecording || voice?.isTranscribing}
|
||||
disabled={
|
||||
!input.trim() || voice?.isRecording || voice?.isTranscribing
|
||||
}
|
||||
className="cursor-pointer rounded-full bg-[var(--accent-orange)] p-2 text-white shadow-sm transition-all duration-200 hover:bg-[var(--accent-orange-bright)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
@@ -341,12 +347,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
anchorRef={textareaRef}
|
||||
/>
|
||||
{voice?.isRecording ? (
|
||||
<div
|
||||
className="flex min-h-[42px] flex-1 items-center justify-center gap-1 rounded-2xl border border-red-500/50 bg-muted/50 px-4 py-2.5 pr-[4.5rem]"
|
||||
>
|
||||
<div className="flex min-h-[42px] flex-1 items-center justify-center gap-1 rounded-2xl border border-red-500/50 bg-muted/50 px-4 py-2.5 pr-[4.5rem]">
|
||||
{voice.audioLevels.map((level, i) => (
|
||||
<div
|
||||
key={i}
|
||||
key={i.toString()}
|
||||
className="w-1 rounded-full bg-red-500 transition-all duration-75"
|
||||
style={{
|
||||
height: `${Math.max(4, Math.min(20, level * 0.6))}px`,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { FileText, X } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { SelectedTextData } from '@/lib/selected-text/selectedTextStorage'
|
||||
|
||||
const MAX_DISPLAY_LENGTH = 200
|
||||
|
||||
interface ChatSelectedTextProps {
|
||||
selectedText: SelectedTextData
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export const ChatSelectedText: FC<ChatSelectedTextProps> = ({
|
||||
selectedText,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const truncated =
|
||||
selectedText.text.length > MAX_DISPLAY_LENGTH
|
||||
? `${selectedText.text.slice(0, MAX_DISPLAY_LENGTH)}...`
|
||||
: selectedText.text
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-2">
|
||||
<div className="relative rounded-lg border border-[var(--accent-orange)]/30 bg-accent/30">
|
||||
<div className="flex items-start gap-2 px-3 py-2">
|
||||
<FileText className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-[var(--accent-orange)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 truncate font-medium text-[10px] text-muted-foreground">
|
||||
{selectedText.pageTitle}
|
||||
</div>
|
||||
<div className="line-clamp-3 text-foreground text-xs leading-relaxed">
|
||||
“{truncated}”
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-background"
|
||||
title="Remove selected text"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,12 +21,14 @@ import {
|
||||
useConversations,
|
||||
} from '@/lib/conversations/conversationStorage'
|
||||
import { formatConversationHistory } from '@/lib/conversations/formatConversationHistory'
|
||||
import { useInvalidateCredits } from '@/lib/credits/useCredits'
|
||||
import { declinedAppsStorage } from '@/lib/declined-apps/storage'
|
||||
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
|
||||
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
|
||||
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
|
||||
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
|
||||
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
|
||||
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
|
||||
import type { ChatMode } from './chatTypes'
|
||||
@@ -85,6 +87,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
selectedLlmProvider,
|
||||
isLoadingProviders,
|
||||
} = useChatRefs()
|
||||
const invalidateCredits = useInvalidateCredits()
|
||||
|
||||
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
|
||||
|
||||
@@ -165,8 +168,34 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
const modeRef = useRef<ChatMode>(mode)
|
||||
const textToActionRef = useRef<Map<string, ChatAction>>(textToAction)
|
||||
const workingDirRef = useRef<string | undefined>(undefined)
|
||||
const selectionMapRef = useRef<
|
||||
Record<string, { text: string; url: string; title: string }>
|
||||
>({})
|
||||
const pendingSelectionTabKeyRef = useRef<string | null>(null)
|
||||
const messagesRef = useRef<UIMessage[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const toRef = (
|
||||
map: Record<string, { text: string; pageUrl: string; pageTitle: string }>,
|
||||
) => {
|
||||
const result: Record<
|
||||
string,
|
||||
{ text: string; url: string; title: string }
|
||||
> = {}
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
result[k] = { text: v.text, url: v.pageUrl, title: v.pageTitle }
|
||||
}
|
||||
return result
|
||||
}
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
selectionMapRef.current = toRef(map)
|
||||
})
|
||||
const unwatchText = selectedTextStorage.watch((map) => {
|
||||
selectionMapRef.current = toRef(map)
|
||||
})
|
||||
return () => unwatchText()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
selectedWorkspaceStorage.getValue().then((folder) => {
|
||||
workingDirRef.current = folder?.path
|
||||
@@ -210,6 +239,9 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
currentWindow: true,
|
||||
})
|
||||
const activeTab = activeTabsList?.[0] ?? undefined
|
||||
const activeTabSelection = activeTab?.id
|
||||
? (selectionMapRef.current[String(activeTab.id)] ?? null)
|
||||
: null
|
||||
const message = getLastMessageText(messages)
|
||||
const provider =
|
||||
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
|
||||
@@ -287,7 +319,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
: history.map((m) => `${m.role}: ${m.content}`).join('\n')
|
||||
: undefined
|
||||
|
||||
return {
|
||||
const result = {
|
||||
api: `${agentUrlRef.current}/chat`,
|
||||
body: {
|
||||
message,
|
||||
@@ -308,6 +340,9 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
secretAccessKey: provider?.secretAccessKey,
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
// ChatGPT Pro (Codex)
|
||||
reasoningEffort: provider?.reasoningEffort,
|
||||
reasoningSummary: provider?.reasoningSummary,
|
||||
browserContext,
|
||||
userSystemPrompt:
|
||||
options?.origin === 'newtab'
|
||||
@@ -319,8 +354,21 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
supportsImages: provider?.supportsImages,
|
||||
previousConversation,
|
||||
declinedApps: declinedApps.length > 0 ? declinedApps : undefined,
|
||||
selectedText: activeTabSelection?.text,
|
||||
selectedTextSource: activeTabSelection
|
||||
? {
|
||||
url: activeTabSelection.url,
|
||||
title: activeTabSelection.title,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
// Track which tab's selection was sent so we can clear it on success
|
||||
pendingSelectionTabKeyRef.current =
|
||||
activeTabSelection && activeTab?.id ? String(activeTab.id) : null
|
||||
|
||||
return result
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -414,6 +462,19 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
|
||||
if (!justFinished) return
|
||||
|
||||
// Clear the selected text that was sent with this request
|
||||
const tabKey = pendingSelectionTabKeyRef.current
|
||||
if (tabKey) {
|
||||
pendingSelectionTabKeyRef.current = null
|
||||
delete selectionMapRef.current[tabKey]
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
if (map[tabKey]) {
|
||||
const { [tabKey]: _, ...rest } = map
|
||||
selectedTextStorage.setValue(rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const messagesToSave = messages.filter((m) => m.parts?.length > 0)
|
||||
if (messagesToSave.length === 0) return
|
||||
|
||||
@@ -422,8 +483,14 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
} else {
|
||||
saveLocalConversation(conversationIdRef.current, messagesToSave)
|
||||
}
|
||||
|
||||
invalidateCredits()
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
if (chatError) invalidateCredits()
|
||||
}, [chatError, invalidateCredits])
|
||||
|
||||
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
|
||||
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
|
||||
const pendingMessageRef = useRef<{
|
||||
@@ -443,6 +510,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
if (pending.action) {
|
||||
setTextToAction((prev) => {
|
||||
const next = new Map(prev)
|
||||
// biome-ignore lint/style/noNonNullAssertion: guarded by if (pending.action) above
|
||||
next.set(pending.text, pending.action!)
|
||||
return next
|
||||
})
|
||||
|
||||
@@ -19,6 +19,10 @@ function extractTabId(toolPart: ToolUIPart | null): number | undefined {
|
||||
return input?.tabId
|
||||
}
|
||||
|
||||
function sendGlow(tabId: number, message: GlowMessage): void {
|
||||
chrome.tabs.sendMessage(tabId, message).catch(() => {})
|
||||
}
|
||||
|
||||
export const useNotifyActiveTab = ({
|
||||
messages,
|
||||
status,
|
||||
@@ -28,7 +32,10 @@ export const useNotifyActiveTab = ({
|
||||
status: ChatStatus
|
||||
conversationId: string
|
||||
}) => {
|
||||
const lastTabIdRef = useRef<number | null>(null)
|
||||
// Track the single tab currently glowing
|
||||
const activeTabIdRef = useRef<number | null>(null)
|
||||
// Track all tabs that have been glowed during this stream (for cleanup)
|
||||
const allGlowedTabsRef = useRef<Set<number>>(new Set())
|
||||
|
||||
const lastMessage = messages?.[messages.length - 1]
|
||||
|
||||
@@ -41,27 +48,35 @@ export const useNotifyActiveTab = ({
|
||||
|
||||
useEffect(() => {
|
||||
const isStreaming = status === 'streaming'
|
||||
const previousTabId = lastTabIdRef.current
|
||||
|
||||
if (!isStreaming) {
|
||||
if (previousTabId) {
|
||||
// Deactivate ALL tabs that were glowed during this stream
|
||||
const allGlowed = allGlowedTabsRef.current
|
||||
if (allGlowed.size > 0) {
|
||||
const deactivate = async () => {
|
||||
// Capture tab IDs before any async work to avoid race with clear()
|
||||
const tabIds = Array.from(allGlowed)
|
||||
allGlowed.clear()
|
||||
|
||||
const alreadyShown = await firstRunConfettiShownStorage.getValue()
|
||||
const deactivateMessage: GlowMessage = {
|
||||
conversationId,
|
||||
isActive: false,
|
||||
showConfetti: !alreadyShown,
|
||||
let showConfetti = !alreadyShown
|
||||
|
||||
for (const tabId of tabIds) {
|
||||
sendGlow(tabId, {
|
||||
conversationId,
|
||||
isActive: false,
|
||||
showConfetti,
|
||||
})
|
||||
showConfetti = false
|
||||
}
|
||||
chrome.tabs
|
||||
.sendMessage(previousTabId, deactivateMessage)
|
||||
.catch(() => {})
|
||||
|
||||
if (!alreadyShown) {
|
||||
await firstRunConfettiShownStorage.setValue(true)
|
||||
}
|
||||
}
|
||||
deactivate()
|
||||
lastTabIdRef.current = null
|
||||
}
|
||||
activeTabIdRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,34 +85,41 @@ export const useNotifyActiveTab = ({
|
||||
let cancelled = false
|
||||
|
||||
const activate = async () => {
|
||||
let targetTabId = toolTabId ?? previousTabId ?? undefined
|
||||
let targetTabId = toolTabId ?? undefined
|
||||
|
||||
if (!targetTabId) {
|
||||
const tabs = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
})
|
||||
targetTabId = tabs[0]?.id
|
||||
// Fallback: use the currently active tab, or query browser
|
||||
if (activeTabIdRef.current) {
|
||||
targetTabId = activeTabIdRef.current
|
||||
} else {
|
||||
const tabs = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
})
|
||||
targetTabId = tabs[0]?.id
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled || !targetTabId) return
|
||||
|
||||
const previousTabId = activeTabIdRef.current
|
||||
|
||||
// If the agent moved to a different tab, deactivate the previous one
|
||||
if (previousTabId && previousTabId !== targetTabId) {
|
||||
const deactivateMessage: GlowMessage = {
|
||||
sendGlow(previousTabId, {
|
||||
conversationId,
|
||||
isActive: false,
|
||||
}
|
||||
chrome.tabs
|
||||
.sendMessage(previousTabId, deactivateMessage)
|
||||
.catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
const activateMessage: GlowMessage = {
|
||||
// Activate glow on the target tab
|
||||
sendGlow(targetTabId, {
|
||||
conversationId,
|
||||
isActive: true,
|
||||
}
|
||||
chrome.tabs.sendMessage(targetTabId, activateMessage).catch(() => {})
|
||||
lastTabIdRef.current = targetTabId
|
||||
})
|
||||
|
||||
activeTabIdRef.current = targetTabId
|
||||
allGlowedTabsRef.current.add(targetTabId)
|
||||
}
|
||||
|
||||
activate()
|
||||
|
||||
@@ -45,6 +45,14 @@ export enum Feature {
|
||||
MEMORY_SUPPORT = 'MEMORY_SUPPORT',
|
||||
// Skills page: agent skills viewer and editor
|
||||
SKILLS_SUPPORT = 'SKILLS_SUPPORT',
|
||||
// ChatGPT Pro OAuth LLM provider
|
||||
CHATGPT_PRO_SUPPORT = 'CHATGPT_PRO_SUPPORT',
|
||||
// GitHub Copilot OAuth LLM provider
|
||||
GITHUB_COPILOT_SUPPORT = 'GITHUB_COPILOT_SUPPORT',
|
||||
// Qwen Code OAuth LLM provider
|
||||
QWEN_CODE_SUPPORT = 'QWEN_CODE_SUPPORT',
|
||||
// Credit-based usage tracking
|
||||
CREDITS_SUPPORT = 'CREDITS_SUPPORT',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +80,10 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
|
||||
[Feature.VERTICAL_TABS_SUPPORT]: { minBrowserOSVersion: '0.42.0.0' },
|
||||
[Feature.MEMORY_SUPPORT]: { minServerVersion: '0.0.73' },
|
||||
[Feature.SKILLS_SUPPORT]: { minBrowserOSVersion: '0.43.0.0' },
|
||||
[Feature.CHATGPT_PRO_SUPPORT]: { minServerVersion: '0.0.77' },
|
||||
[Feature.GITHUB_COPILOT_SUPPORT]: { minServerVersion: '0.0.77' },
|
||||
[Feature.QWEN_CODE_SUPPORT]: { minServerVersion: '0.0.77' },
|
||||
[Feature.CREDITS_SUPPORT]: { minServerVersion: '0.0.78' },
|
||||
}
|
||||
|
||||
function parseVersion(version: string): number[] {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
|
||||
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
|
||||
import { createBrowserOSAction } from './types'
|
||||
|
||||
interface ChatActionsConfig {
|
||||
/** Analytics event names scoped to the origin */
|
||||
events: {
|
||||
modeChanged: string
|
||||
stopClicked: string
|
||||
suggestionClicked: string
|
||||
tabToggled: string
|
||||
tabRemoved: string
|
||||
aiTriggered: string
|
||||
voiceRecordingStarted: string
|
||||
voiceRecordingStopped: string
|
||||
voiceTranscriptionCompleted: string
|
||||
voiceError: string
|
||||
}
|
||||
/** Auto-attach current active tab on mount (sidepanel only) */
|
||||
autoAttachActiveTab?: boolean
|
||||
}
|
||||
|
||||
export function useChatActions(config: ChatActionsConfig) {
|
||||
const session = useChatSessionContext()
|
||||
const { mode, setMode, sendMessage, stop, messages } = session
|
||||
|
||||
const voice = useVoiceInput()
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Auto-attach current tab on mount (sidepanel)
|
||||
useEffect(() => {
|
||||
if (!config.autoAttachActiveTab) return
|
||||
;(async () => {
|
||||
const currentTab = (
|
||||
await chrome.tabs.query({ active: true, currentWindow: true })
|
||||
).filter((tab) => tab.url?.startsWith('http'))
|
||||
setAttachedTabs(currentTab)
|
||||
})()
|
||||
}, [config.autoAttachActiveTab])
|
||||
|
||||
// Voice transcript → input
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on transcript/transcribing change
|
||||
useEffect(() => {
|
||||
if (voice.transcript && !voice.isTranscribing) {
|
||||
setInput((prev) => {
|
||||
const separator = prev.trim() ? ' ' : ''
|
||||
return prev + separator + voice.transcript
|
||||
})
|
||||
track(config.events.voiceTranscriptionCompleted)
|
||||
voice.clearTranscript()
|
||||
}
|
||||
}, [voice.transcript, voice.isTranscribing])
|
||||
|
||||
// Track voice errors
|
||||
useEffect(() => {
|
||||
if (voice.error) {
|
||||
track(config.events.voiceError, { error: voice.error })
|
||||
}
|
||||
}, [voice.error, config.events.voiceError])
|
||||
|
||||
const handleModeChange = (newMode: ChatMode) => {
|
||||
track(config.events.modeChanged, { from: mode, to: newMode })
|
||||
setMode(newMode)
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
track(config.events.stopClicked)
|
||||
stop()
|
||||
}
|
||||
|
||||
const toggleTabSelection = (tab: chrome.tabs.Tab) => {
|
||||
setAttachedTabs((prev) => {
|
||||
const isSelected = prev.some((t) => t.id === tab.id)
|
||||
track(config.events.tabToggled, {
|
||||
action: isSelected ? 'removed' : 'added',
|
||||
})
|
||||
if (isSelected) {
|
||||
return prev.filter((t) => t.id !== tab.id)
|
||||
}
|
||||
return [...prev, tab]
|
||||
})
|
||||
}
|
||||
|
||||
const removeTab = (tabId?: number) => {
|
||||
track(config.events.tabRemoved)
|
||||
setAttachedTabs((prev) => prev.filter((t) => t.id !== tabId))
|
||||
}
|
||||
|
||||
const executeMessage = (customMessageText?: string) => {
|
||||
const messageText = customMessageText ? customMessageText : input.trim()
|
||||
if (!messageText) return
|
||||
|
||||
if (attachedTabs.length) {
|
||||
const action = createBrowserOSAction({
|
||||
mode,
|
||||
message: messageText,
|
||||
tabs: attachedTabs,
|
||||
})
|
||||
sendMessage({ text: messageText, action })
|
||||
} else {
|
||||
sendMessage({ text: messageText })
|
||||
}
|
||||
setInput('')
|
||||
setAttachedTabs([])
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (messages.length === 0) {
|
||||
track(config.events.aiTriggered, {
|
||||
mode,
|
||||
tabs_count: attachedTabs.length,
|
||||
})
|
||||
}
|
||||
executeMessage()
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
track(config.events.suggestionClicked, { mode })
|
||||
executeMessage(suggestion)
|
||||
}
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
const started = await voice.startRecording()
|
||||
if (started) {
|
||||
track(config.events.voiceRecordingStarted)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRecording = async () => {
|
||||
await voice.stopRecording()
|
||||
track(config.events.voiceRecordingStopped)
|
||||
}
|
||||
|
||||
const voiceState = {
|
||||
isRecording: voice.isRecording,
|
||||
isTranscribing: voice.isTranscribing,
|
||||
audioLevels: voice.audioLevels,
|
||||
error: voice.error,
|
||||
onStartRecording: handleStartRecording,
|
||||
onStopRecording: handleStopRecording,
|
||||
}
|
||||
|
||||
const { stop: _stop, ...restSession } = session
|
||||
|
||||
return {
|
||||
...restSession,
|
||||
input,
|
||||
setInput,
|
||||
attachedTabs,
|
||||
setAttachedTabs,
|
||||
mounted,
|
||||
voiceState,
|
||||
handleModeChange,
|
||||
handleStop,
|
||||
toggleTabSelection,
|
||||
removeTab,
|
||||
executeMessage,
|
||||
handleSubmit,
|
||||
handleSuggestionClick,
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,41 @@ export const CONVERSATION_RESET_EVENT = 'ui.conversation.reset'
|
||||
/** @public */
|
||||
export const AI_PROVIDER_ADDED_EVENT = 'settings.ai_provider.added'
|
||||
|
||||
/** @public */
|
||||
export const CHATGPT_PRO_OAUTH_STARTED_EVENT =
|
||||
'settings.chatgpt_pro.oauth_started'
|
||||
|
||||
/** @public */
|
||||
export const CHATGPT_PRO_OAUTH_COMPLETED_EVENT =
|
||||
'settings.chatgpt_pro.oauth_completed'
|
||||
|
||||
/** @public */
|
||||
export const CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT =
|
||||
'settings.chatgpt_pro.oauth_disconnected'
|
||||
|
||||
/** @public */
|
||||
export const GITHUB_COPILOT_OAUTH_STARTED_EVENT =
|
||||
'settings.github_copilot.oauth_started'
|
||||
|
||||
/** @public */
|
||||
export const GITHUB_COPILOT_OAUTH_COMPLETED_EVENT =
|
||||
'settings.github_copilot.oauth_completed'
|
||||
|
||||
/** @public */
|
||||
export const GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT =
|
||||
'settings.github_copilot.oauth_disconnected'
|
||||
|
||||
/** @public */
|
||||
export const QWEN_CODE_OAUTH_STARTED_EVENT = 'settings.qwen_code.oauth_started'
|
||||
|
||||
/** @public */
|
||||
export const QWEN_CODE_OAUTH_COMPLETED_EVENT =
|
||||
'settings.qwen_code.oauth_completed'
|
||||
|
||||
/** @public */
|
||||
export const QWEN_CODE_OAUTH_DISCONNECTED_EVENT =
|
||||
'settings.qwen_code.oauth_disconnected'
|
||||
|
||||
/** @public */
|
||||
export const HUB_PROVIDER_ADDED_EVENT = 'settings.hub_provider.added'
|
||||
|
||||
@@ -118,6 +153,21 @@ export const NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT =
|
||||
/** @public */
|
||||
export const NEWTAB_CHAT_MODE_CHANGED_EVENT = 'newtab.chat.mode_changed'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_VOICE_RECORDING_STARTED_EVENT =
|
||||
'newtab.voice.recording_started'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_VOICE_RECORDING_STOPPED_EVENT =
|
||||
'newtab.voice.recording_stopped'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT =
|
||||
'newtab.voice.transcription_completed'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_VOICE_ERROR_EVENT = 'newtab.voice.error'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_DELETED_EVENT = 'settings.workflow.deleted'
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const LOW_THRESHOLD = 30
|
||||
|
||||
export function getCreditTextColor(credits: number): string {
|
||||
if (credits <= 0) return 'text-red-500'
|
||||
if (credits <= LOW_THRESHOLD) return 'text-yellow-500'
|
||||
return 'text-green-500'
|
||||
}
|
||||
|
||||
export function getCreditBarColor(credits: number): string {
|
||||
if (credits <= 0) return 'bg-red-500'
|
||||
if (credits <= LOW_THRESHOLD) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
export interface CreditsInfo {
|
||||
credits: number
|
||||
dailyLimit: number
|
||||
lastResetAt?: string
|
||||
}
|
||||
|
||||
const CREDITS_QUERY_KEY = ['credits']
|
||||
|
||||
async function fetchCredits(): Promise<CreditsInfo> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
const response = await fetch(`${baseUrl}/credits`)
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch credits: ${response.status}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export function useCredits() {
|
||||
return useQuery<CreditsInfo>({
|
||||
queryKey: CREDITS_QUERY_KEY,
|
||||
queryFn: fetchCredits,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
})
|
||||
}
|
||||
|
||||
export function useInvalidateCredits() {
|
||||
const queryClient = useQueryClient()
|
||||
return () => queryClient.invalidateQueries({ queryKey: CREDITS_QUERY_KEY })
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Client-side OAuth Device Code flow.
|
||||
* Used for providers where server-side fetch is blocked by WAF (e.g. Qwen).
|
||||
* The extension makes requests using Chrome's network stack which bypasses
|
||||
* TLS fingerprint-based WAF detection.
|
||||
*/
|
||||
|
||||
export interface ClientAuthConfig {
|
||||
deviceCodeEndpoint: string
|
||||
tokenEndpoint: string
|
||||
clientId: string
|
||||
scopes: string
|
||||
requiresPKCE: boolean
|
||||
contentType: 'json' | 'form'
|
||||
}
|
||||
|
||||
interface DeviceCodeData {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
verification_uri_complete?: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface TokenResult {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
export async function requestDeviceCode(
|
||||
auth: ClientAuthConfig,
|
||||
): Promise<{ deviceData: DeviceCodeData; codeVerifier?: string }> {
|
||||
let codeVerifier: string | undefined
|
||||
const params: Record<string, string> = {
|
||||
client_id: auth.clientId,
|
||||
scope: auth.scopes,
|
||||
}
|
||||
|
||||
if (auth.requiresPKCE) {
|
||||
codeVerifier = generateCodeVerifier()
|
||||
params.code_challenge = await generateCodeChallenge(codeVerifier)
|
||||
params.code_challenge_method = 'S256'
|
||||
}
|
||||
|
||||
const res = await authFetch(auth.deviceCodeEndpoint, params, auth.contentType)
|
||||
|
||||
// WAF captcha detected — open the site for user to solve, then retry
|
||||
const ct = res.headers.get('content-type') ?? ''
|
||||
if (!ct.includes('application/json')) {
|
||||
const baseUrl = new URL(auth.deviceCodeEndpoint).origin
|
||||
window.open(baseUrl, '_blank')
|
||||
throw new Error(
|
||||
'Please complete the verification in the opened tab, then click USE again.',
|
||||
)
|
||||
}
|
||||
if (!res.ok) throw new Error(`Device code request failed: ${res.status}`)
|
||||
|
||||
const deviceData = (await res.json()) as DeviceCodeData
|
||||
if (!deviceData.device_code || !deviceData.user_code) {
|
||||
throw new Error('Invalid device code response')
|
||||
}
|
||||
|
||||
return { deviceData, codeVerifier }
|
||||
}
|
||||
|
||||
export function startTokenPolling(
|
||||
auth: ClientAuthConfig,
|
||||
deviceData: DeviceCodeData,
|
||||
codeVerifier: string | undefined,
|
||||
onToken: (token: TokenResult) => void,
|
||||
): void {
|
||||
let interval = deviceData.interval
|
||||
const deadline = Date.now() + deviceData.expires_in * 1000
|
||||
const safetyMargin = 3
|
||||
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) return
|
||||
|
||||
const params: Record<string, string> = {
|
||||
client_id: auth.clientId,
|
||||
device_code: deviceData.device_code,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
}
|
||||
if (codeVerifier) params.code_verifier = codeVerifier
|
||||
|
||||
try {
|
||||
const res = await authFetch(auth.tokenEndpoint, params, auth.contentType)
|
||||
|
||||
// WAF returned HTML — retry later
|
||||
const ct = res.headers.get('content-type') ?? ''
|
||||
if (!ct.includes('application/json')) {
|
||||
setTimeout(poll, (interval + safetyMargin) * 1000)
|
||||
return
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
error?: string
|
||||
interval?: number
|
||||
}
|
||||
|
||||
if (data.access_token) {
|
||||
onToken({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? '',
|
||||
expiresIn: data.expires_in ?? 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (data.error === 'authorization_pending') {
|
||||
setTimeout(poll, (interval + safetyMargin) * 1000)
|
||||
return
|
||||
}
|
||||
if (data.error === 'slow_down') {
|
||||
interval = (data.interval ?? interval) + 5
|
||||
setTimeout(poll, (interval + safetyMargin) * 1000)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
setTimeout(poll, (interval + safetyMargin) * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(poll, (interval + safetyMargin) * 1000)
|
||||
}
|
||||
|
||||
function authFetch(
|
||||
endpoint: string,
|
||||
params: Record<string, string>,
|
||||
contentType: 'json' | 'form',
|
||||
): Promise<Response> {
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type':
|
||||
contentType === 'form'
|
||||
? 'application/x-www-form-urlencoded'
|
||||
: 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body:
|
||||
contentType === 'form'
|
||||
? new URLSearchParams(params).toString()
|
||||
: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
||||
return base64UrlEncode(bytes)
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(verifier),
|
||||
)
|
||||
return base64UrlEncode(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...bytes))
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
Ollama,
|
||||
OpenAI,
|
||||
OpenRouter,
|
||||
Qwen,
|
||||
} from '@lobehub/icons'
|
||||
import { Bot } from 'lucide-react'
|
||||
import { Bot, Github } from 'lucide-react'
|
||||
import type { FC, SVGProps } from 'react'
|
||||
import ProductLogoSvg from '@/assets/product_logo.svg'
|
||||
import type { ProviderType } from './types'
|
||||
@@ -32,6 +33,9 @@ const providerIconMap: Record<ProviderType, IconComponent | null> = {
|
||||
bedrock: Bedrock,
|
||||
browseros: null,
|
||||
moonshot: Kimi,
|
||||
'chatgpt-pro': OpenAI,
|
||||
'github-copilot': Github,
|
||||
'qwen-code': Qwen,
|
||||
}
|
||||
|
||||
interface ProviderIconProps {
|
||||
|
||||
@@ -20,6 +20,33 @@ export interface ProviderTemplate {
|
||||
* @public
|
||||
*/
|
||||
export const providerTemplates: ProviderTemplate[] = [
|
||||
{
|
||||
id: 'chatgpt-pro',
|
||||
name: 'ChatGPT Plus/Pro',
|
||||
defaultBaseUrl: 'https://chatgpt.com/backend-api',
|
||||
defaultModelId: 'gpt-5.3-codex',
|
||||
supportsImages: true,
|
||||
contextWindow: 400000,
|
||||
setupGuideUrl: 'https://docs.browseros.com/features/chatgpt-pro-oauth',
|
||||
},
|
||||
{
|
||||
id: 'github-copilot',
|
||||
name: 'GitHub Copilot',
|
||||
defaultBaseUrl: 'https://api.githubcopilot.com',
|
||||
defaultModelId: 'gpt-5-mini',
|
||||
supportsImages: true,
|
||||
contextWindow: 128000,
|
||||
setupGuideUrl: 'https://docs.browseros.com/features/github-copilot-oauth',
|
||||
},
|
||||
{
|
||||
id: 'qwen-code',
|
||||
name: 'Qwen Code',
|
||||
defaultBaseUrl: 'https://portal.qwen.ai/v1',
|
||||
defaultModelId: 'coder-model',
|
||||
supportsImages: true,
|
||||
contextWindow: 1000000,
|
||||
setupGuideUrl: 'https://docs.browseros.com/features/qwen-code-oauth',
|
||||
},
|
||||
{
|
||||
id: 'moonshot',
|
||||
name: 'Moonshot AI',
|
||||
@@ -129,6 +156,9 @@ export const providerTemplates: ProviderTemplate[] = [
|
||||
* @public
|
||||
*/
|
||||
export const providerTypeOptions: { value: ProviderType; label: string }[] = [
|
||||
{ value: 'chatgpt-pro', label: 'ChatGPT Plus/Pro' },
|
||||
{ value: 'github-copilot', label: 'GitHub Copilot' },
|
||||
{ value: 'qwen-code', label: 'Qwen Code' },
|
||||
{ value: 'moonshot', label: 'Moonshot AI' },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
@@ -157,6 +187,9 @@ export const getProviderTemplate = (
|
||||
* Auto-fills when user selects a provider type
|
||||
*/
|
||||
export const DEFAULT_BASE_URLS: Record<ProviderType, string> = {
|
||||
'chatgpt-pro': 'https://chatgpt.com/backend-api',
|
||||
'github-copilot': 'https://api.githubcopilot.com',
|
||||
'qwen-code': 'https://portal.qwen.ai/v1',
|
||||
moonshot: 'https://api.moonshot.ai/v1',
|
||||
anthropic: 'https://api.anthropic.com/v1',
|
||||
openai: 'https://api.openai.com/v1',
|
||||
|
||||
@@ -14,6 +14,9 @@ export type ProviderType =
|
||||
| 'bedrock'
|
||||
| 'browseros'
|
||||
| 'moonshot'
|
||||
| 'chatgpt-pro'
|
||||
| 'github-copilot'
|
||||
| 'qwen-code'
|
||||
|
||||
/**
|
||||
* LLM Provider configuration
|
||||
@@ -56,6 +59,10 @@ export interface LlmProviderConfig {
|
||||
region?: string
|
||||
/** AWS session token (for temporary STS credentials) */
|
||||
sessionToken?: string
|
||||
|
||||
// ChatGPT Pro (Codex) fields
|
||||
reasoningEffort?: 'none' | 'low' | 'medium' | 'high'
|
||||
reasoningSummary?: 'auto' | 'concise' | 'detailed'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import {
|
||||
type ClientAuthConfig,
|
||||
requestDeviceCode,
|
||||
startTokenPolling,
|
||||
} from './client-oauth'
|
||||
import { getProviderTemplate } from './providerTemplates'
|
||||
import type { LlmProviderConfig, ProviderType } from './types'
|
||||
import { useOAuthStatus } from './useOAuthStatus'
|
||||
|
||||
export interface OAuthProviderFlowConfig {
|
||||
providerType: ProviderType
|
||||
displayName: string
|
||||
startedEvent: string
|
||||
completedEvent: string
|
||||
disconnectedEvent: string
|
||||
/** Client-side auth for providers with WAF-protected endpoints */
|
||||
clientAuth?: ClientAuthConfig
|
||||
}
|
||||
|
||||
interface OAuthProviderFlowReturn {
|
||||
status: { authenticated: boolean; email?: string } | null
|
||||
disconnect: () => Promise<void>
|
||||
startOAuthFlow: (agentServerUrl: string | undefined) => Promise<void>
|
||||
}
|
||||
|
||||
export function useOAuthProviderFlow(
|
||||
config: OAuthProviderFlowConfig,
|
||||
providers: LlmProviderConfig[],
|
||||
saveProvider: (provider: LlmProviderConfig) => Promise<void> | void,
|
||||
): OAuthProviderFlowReturn {
|
||||
const { status, startPolling, disconnect } = useOAuthStatus(
|
||||
config.providerType,
|
||||
)
|
||||
const flowStartedRef = useRef(false)
|
||||
|
||||
// Auto-create provider when OAuth completes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
|
||||
useEffect(() => {
|
||||
if (!status?.authenticated) return
|
||||
if (!flowStartedRef.current) return
|
||||
if (providers.some((p) => p.type === config.providerType)) return
|
||||
|
||||
const now = Date.now()
|
||||
try {
|
||||
const template = getProviderTemplate(config.providerType)
|
||||
saveProvider({
|
||||
id: `${config.providerType}-${now}`,
|
||||
type: config.providerType,
|
||||
name: `${config.displayName}${status.email ? ` (${status.email})` : ''}`,
|
||||
modelId: template?.defaultModelId ?? '',
|
||||
supportsImages: template?.supportsImages ?? true,
|
||||
contextWindow: template?.contextWindow ?? 128000,
|
||||
temperature: 0.2,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
track(config.completedEvent, { email: status.email })
|
||||
toast.success(`${config.displayName} Connected`, {
|
||||
description: status.email
|
||||
? `Authenticated as ${status.email}`
|
||||
: `Successfully authenticated with ${config.displayName}`,
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error(`Failed to create ${config.displayName} provider`, {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
} finally {
|
||||
flowStartedRef.current = false
|
||||
}
|
||||
}, [status?.authenticated])
|
||||
|
||||
async function startOAuthFlow(agentServerUrl: string | undefined) {
|
||||
if (!agentServerUrl) {
|
||||
toast.error('Server not available', {
|
||||
description: 'Cannot start OAuth flow without server connection.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
flowStartedRef.current = true
|
||||
|
||||
try {
|
||||
if (config.clientAuth) {
|
||||
await handleClientAuth(config.clientAuth, agentServerUrl)
|
||||
} else {
|
||||
await handleServerAuth(agentServerUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
flowStartedRef.current = false
|
||||
toast.error(`Failed to start ${config.displayName} authentication`, {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side: extension handles device code + polling, sends token to server
|
||||
async function handleClientAuth(auth: ClientAuthConfig, serverUrl: string) {
|
||||
const { deviceData, codeVerifier } = await requestDeviceCode(auth)
|
||||
|
||||
const verificationUri =
|
||||
deviceData.verification_uri_complete ?? deviceData.verification_uri
|
||||
window.open(verificationUri, '_blank')
|
||||
track(config.startedEvent)
|
||||
toast.info(`Enter code: ${deviceData.user_code}`, {
|
||||
description: `Paste this code on the ${config.displayName} page that just opened.`,
|
||||
duration: 60_000,
|
||||
})
|
||||
|
||||
startTokenPolling(auth, deviceData, codeVerifier, async (token) => {
|
||||
await fetch(`${serverUrl}/oauth/${config.providerType}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(token),
|
||||
})
|
||||
startPolling()
|
||||
})
|
||||
}
|
||||
|
||||
// Server-side: server handles device code + polling
|
||||
async function handleServerAuth(agentServerUrl: string) {
|
||||
const res = await fetch(
|
||||
`${agentServerUrl}/oauth/${config.providerType}/start`,
|
||||
)
|
||||
|
||||
if (res.headers.get('content-type')?.includes('application/json')) {
|
||||
const data = (await res.json()) as {
|
||||
userCode?: string
|
||||
verificationUri?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
if (!res.ok || data.error) {
|
||||
throw new Error(data.error || `Server returned ${res.status}`)
|
||||
}
|
||||
if (!data.userCode || !data.verificationUri) {
|
||||
throw new Error('Invalid response from server')
|
||||
}
|
||||
|
||||
window.open(data.verificationUri, '_blank')
|
||||
startPolling()
|
||||
track(config.startedEvent)
|
||||
toast.info(`Enter code: ${data.userCode}`, {
|
||||
description: `Paste this code on the ${config.displayName} page that just opened.`,
|
||||
duration: 60_000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// PKCE redirect flow
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`)
|
||||
window.open(res.url, '_blank')
|
||||
startPolling()
|
||||
track(config.startedEvent)
|
||||
toast.info(`Authenticating with ${config.displayName}`, {
|
||||
description: 'Complete the login in the opened tab.',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
disconnect,
|
||||
startOAuthFlow,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
interface OAuthStatus {
|
||||
authenticated: boolean
|
||||
email?: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface UseOAuthStatusReturn {
|
||||
status: OAuthStatus | null
|
||||
isPolling: boolean
|
||||
startPolling: () => void
|
||||
stopPolling: () => void
|
||||
refresh: () => Promise<OAuthStatus | null>
|
||||
disconnect: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useOAuthStatus(provider: string): UseOAuthStatusReturn {
|
||||
const [status, setStatus] = useState<OAuthStatus | null>(null)
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
async function fetchStatus(): Promise<OAuthStatus | null> {
|
||||
try {
|
||||
const serverUrl = await getAgentServerUrl()
|
||||
const res = await fetch(`${serverUrl}/oauth/${provider}/status`)
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as OAuthStatus
|
||||
setStatus(data)
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current)
|
||||
if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current)
|
||||
pollIntervalRef.current = null
|
||||
pollTimeoutRef.current = null
|
||||
setIsPolling(false)
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
setIsPolling(true)
|
||||
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
const result = await fetchStatus()
|
||||
if (result?.authenticated) {
|
||||
stopPolling()
|
||||
}
|
||||
}, 2_000)
|
||||
|
||||
pollTimeoutRef.current = setTimeout(stopPolling, 300_000)
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
try {
|
||||
const serverUrl = await getAgentServerUrl()
|
||||
await fetch(`${serverUrl}/oauth/${provider}`, { method: 'DELETE' })
|
||||
setStatus({ authenticated: false, provider })
|
||||
} catch {
|
||||
// Best-effort disconnect
|
||||
}
|
||||
}
|
||||
|
||||
// Initial status check on mount
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
|
||||
useEffect(() => {
|
||||
return () => stopPolling()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
status,
|
||||
isPolling,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
refresh: fetchStatus,
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,4 @@ type ScheduleMessagesProtocol = {
|
||||
const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<ScheduleMessagesProtocol>()
|
||||
|
||||
export { sendMessage as sendScheduleMessage, onMessage as onScheduleMessage }
|
||||
export { onMessage as onScheduleMessage, sendMessage as sendScheduleMessage }
|
||||
|
||||
@@ -12,4 +12,4 @@ type ServerMessagesProtocol = {
|
||||
const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<ServerMessagesProtocol>()
|
||||
|
||||
export { sendMessage as sendServerMessage, onMessage as onServerMessage }
|
||||
export { onMessage as onServerMessage, sendMessage as sendServerMessage }
|
||||
|
||||
@@ -12,6 +12,6 @@ const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<OpenSidePanelWithSearchParams>()
|
||||
|
||||
export {
|
||||
sendMessage as openSidePanelWithSearch,
|
||||
onMessage as onOpenSidePanelWithSearch,
|
||||
sendMessage as openSidePanelWithSearch,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
|
||||
export interface SelectedTextData {
|
||||
text: string
|
||||
pageUrl: string
|
||||
pageTitle: string
|
||||
tabId: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/** Map of tabId → selected text. Each tab's selection is independent. */
|
||||
export const selectedTextStorage = storage.defineItem<
|
||||
Record<string, SelectedTextData>
|
||||
>('local:selectedTextMap', { defaultValue: {} })
|
||||
@@ -45,7 +45,9 @@ async function transcribeAudio(audioBlob: Blob): Promise<string> {
|
||||
const errorBody: { error?: string } = await response
|
||||
.json()
|
||||
.catch(() => ({ error: 'Transcription failed' }))
|
||||
throw new Error(errorBody.error || `Transcription failed: ${response.status}`)
|
||||
throw new Error(
|
||||
errorBody.error || `Transcription failed: ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result: TranscribeResponse = await response.json()
|
||||
@@ -81,6 +83,7 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
setAudioLevels(EMPTY_LEVELS)
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
@@ -171,7 +174,9 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
setIsRecording(true)
|
||||
return true
|
||||
} catch (err) {
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop())
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
track.stop()
|
||||
})
|
||||
streamRef.current = null
|
||||
stopAudioLevelMonitoring()
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"build": "bun run codegen && wxt build",
|
||||
"build:dev": "bun --env-file=.env.development wxt build --mode development",
|
||||
"zip": "wxt zip",
|
||||
"compile": "tsc --noEmit",
|
||||
"compile": "tsgo --noEmit",
|
||||
"lint": "bunx biome check",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"lint:fix": "bunx biome check --write --unsafe",
|
||||
"clean:cache": "rm -rf node_modules/.cache && rm -rf .output/ && rm -rf .wxt/",
|
||||
"codegen": "bun --env-file=.env.development graphql-codegen --config codegen.ts",
|
||||
@@ -79,6 +79,7 @@
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.3.3",
|
||||
"react-router": "^7.12.0",
|
||||
"shiki": "^3.15.0",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"types": ["chrome", "bun"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@ export default defineConfig({
|
||||
permissions: [
|
||||
'topSites',
|
||||
'tabs',
|
||||
'tabGroups',
|
||||
'storage',
|
||||
'sidePanel',
|
||||
'browserOS',
|
||||
|
||||
@@ -173,7 +173,9 @@ async function annotateScreenshot(
|
||||
|
||||
const image = sharp(inputPath)
|
||||
const metadata = await image.metadata()
|
||||
// biome-ignore lint/style/noNonNullAssertion: sharp metadata always has dimensions for valid images
|
||||
const imgWidth = metadata.width!
|
||||
// biome-ignore lint/style/noNonNullAssertion: sharp metadata always has dimensions for valid images
|
||||
const imgHeight = metadata.height!
|
||||
|
||||
const sx = Math.round(action.cssX * dpr)
|
||||
|
||||
@@ -49,10 +49,13 @@ async function callMcpTool(
|
||||
const result = await Promise.race([toolPromise, timeoutPromise])
|
||||
const duration = Date.now() - start
|
||||
|
||||
if ((result as any).isError) {
|
||||
const res = result as Record<string, unknown>
|
||||
if (res.isError) {
|
||||
const content = res.content as
|
||||
| Array<{ type: string; text?: string }>
|
||||
| undefined
|
||||
const errorText =
|
||||
(result as any).content?.find((c: any) => c.type === 'text')?.text ||
|
||||
'Unknown error'
|
||||
content?.find((c) => c.type === 'text')?.text || 'Unknown error'
|
||||
return { success: false, error: errorText, duration }
|
||||
}
|
||||
|
||||
@@ -96,13 +99,19 @@ async function main() {
|
||||
})
|
||||
|
||||
// Try structured content first
|
||||
windowId = (result as any).structuredContent?.windowId
|
||||
tabId = (result as any).structuredContent?.tabId
|
||||
const createRes = result as Record<string, unknown>
|
||||
const structured = createRes.structuredContent as
|
||||
| Record<string, number>
|
||||
| undefined
|
||||
windowId = structured?.windowId ?? 0
|
||||
tabId = structured?.tabId ?? 0
|
||||
|
||||
// Fall back to parsing text
|
||||
if (!windowId || !tabId) {
|
||||
const text =
|
||||
(result as any).content?.find((c: any) => c.type === 'text')?.text || ''
|
||||
const content = createRes.content as
|
||||
| Array<{ type: string; text?: string }>
|
||||
| undefined
|
||||
const text = content?.find((c) => c.type === 'text')?.text || ''
|
||||
const windowMatch = text.match(/window\s+(\d+)/i)
|
||||
const tabMatch =
|
||||
text.match(/Tab ID:\s*(\d+)/i) || text.match(/tab\s+(\d+)/i)
|
||||
|
||||
@@ -90,7 +90,8 @@ export class SingleAgentEvaluator implements AgentEvaluator {
|
||||
timeoutMs,
|
||||
capture,
|
||||
async (signal) => {
|
||||
const result = await agent!.toolLoopAgent.generate({
|
||||
if (!agent) throw new Error('Agent was not initialized')
|
||||
const result = await agent.toolLoopAgent.generate({
|
||||
prompt: task.query,
|
||||
abortSignal: signal,
|
||||
|
||||
|
||||
@@ -63,9 +63,9 @@ export class ScreenshotCapture {
|
||||
}
|
||||
|
||||
private async captureDirect(pageId: number): Promise<string | null> {
|
||||
if (!this.browser) return null
|
||||
try {
|
||||
// browser is guaranteed non-null here — captureDirect is only called when this.browser is truthy
|
||||
const result = await this.browser!.screenshot(pageId, {
|
||||
const result = await this.browser.screenshot(pageId, {
|
||||
format: 'png',
|
||||
fullPage: false,
|
||||
})
|
||||
@@ -74,9 +74,9 @@ export class ScreenshotCapture {
|
||||
} catch (error) {
|
||||
// If page ID is invalid, try listing pages and use the first one
|
||||
try {
|
||||
const pages = await this.browser!.listPages()
|
||||
const pages = await this.browser.listPages()
|
||||
if (pages.length > 0) {
|
||||
const result = await this.browser!.screenshot(pages[0].pageId, {
|
||||
const result = await this.browser.screenshot(pages[0].pageId, {
|
||||
format: 'png',
|
||||
fullPage: false,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.75",
|
||||
"version": "0.0.78",
|
||||
"description": "BrowserOS server",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -63,8 +63,8 @@
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.62",
|
||||
"@ai-sdk/anthropic": "^3.0.46",
|
||||
"@ai-sdk/azure": "^3.0.31",
|
||||
"@ai-sdk/google": "^3.0.30",
|
||||
"@ai-sdk/devtools": "^0.0.15",
|
||||
"@ai-sdk/google": "^3.0.30",
|
||||
"@ai-sdk/mcp": "^1.0.21",
|
||||
"@ai-sdk/openai": "^3.0.30",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
@@ -89,6 +89,7 @@
|
||||
"fuse.js": "^7.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.12.3",
|
||||
"jimp": "^1.6.0",
|
||||
"klavis": "^2.15.0",
|
||||
"pino": "^9.6.0",
|
||||
"posthog-node": "^4.17.0",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@ai-sdk/provider'
|
||||
import { AGENT_LIMITS } from '@browseros/shared/constants/limits'
|
||||
import type { BrowserContext } from '@browseros/shared/schemas/browser-context'
|
||||
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import {
|
||||
type LanguageModel,
|
||||
type ModelMessage,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
wrapLanguageModel,
|
||||
} from 'ai'
|
||||
import type { Browser } from '../browser/browser'
|
||||
import { getSkillsDir } from '../lib/browseros-dir'
|
||||
import type { KlavisClient } from '../lib/clients/klavis/klavis-client'
|
||||
import { logger } from '../lib/logger'
|
||||
import { isSoulBootstrap, readSoul } from '../lib/soul'
|
||||
@@ -150,7 +150,7 @@ export class AiSdkAgent {
|
||||
const isBootstrap = await isSoulBootstrap()
|
||||
|
||||
// Load skills catalog for prompt injection
|
||||
const skills = await loadSkills(getSkillsDir())
|
||||
const skills = await loadSkills()
|
||||
const skillsCatalog =
|
||||
skills.length > 0 ? buildSkillsCatalog(skills) : undefined
|
||||
|
||||
@@ -189,13 +189,27 @@ export class AiSdkAgent {
|
||||
),
|
||||
})
|
||||
|
||||
// Create the ToolLoopAgent
|
||||
// Codex requires store=false — tell the SDK to inline content
|
||||
// instead of using item_reference (which fails with store=false)
|
||||
const isChatGPTPro =
|
||||
config.resolvedConfig.provider === LLM_PROVIDERS.CHATGPT_PRO
|
||||
|
||||
const agent = new ToolLoopAgent({
|
||||
model,
|
||||
instructions,
|
||||
tools,
|
||||
stopWhen: [stepCountIs(AGENT_LIMITS.MAX_TURNS)],
|
||||
prepareStep,
|
||||
...(isChatGPTPro && {
|
||||
providerOptions: {
|
||||
openai: {
|
||||
store: false,
|
||||
reasoningEffort: config.resolvedConfig.reasoningEffort || 'high',
|
||||
reasoningSummary: config.resolvedConfig.reasoningSummary || 'auto',
|
||||
include: ['reasoning.encrypted_content'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
logger.info('Agent session created (v2)', {
|
||||
|
||||
@@ -38,10 +38,34 @@ export function formatBrowserContext(browserContext?: BrowserContext): string {
|
||||
return `${lines.join('\n')}\n\n---\n\n`
|
||||
}
|
||||
|
||||
/** Strip XML-like tags that match our prompt delimiters to prevent injection. */
|
||||
function sanitizeForPrompt(s: string): string {
|
||||
return s.replace(
|
||||
/<\/?(?:selected_text|USER_QUERY|page_context|AGENT_PROMPT|soul|memory_and_identity|security|workspace)[^>]*>/gi,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
export function formatUserMessage(
|
||||
message: string,
|
||||
browserContext?: BrowserContext,
|
||||
selectedText?: string,
|
||||
selectedTextSource?: { url: string; title: string },
|
||||
): string {
|
||||
const contextPrefix = formatBrowserContext(browserContext)
|
||||
return `${contextPrefix}<USER_QUERY>\n${message}\n</USER_QUERY>`
|
||||
|
||||
let selectedTextBlock = ''
|
||||
if (selectedText) {
|
||||
const sanitizedText = sanitizeForPrompt(selectedText)
|
||||
const title = selectedTextSource?.title
|
||||
? sanitizeForPrompt(selectedTextSource.title).replace(/"/g, "'")
|
||||
: ''
|
||||
const url = selectedTextSource?.url
|
||||
? sanitizeForPrompt(selectedTextSource.url)
|
||||
: ''
|
||||
const source = title ? ` (from "${title}"${url ? ` — ${url}` : ''})` : ''
|
||||
selectedTextBlock = `<selected_text${source}>\n${sanitizedText}\n</selected_text>\n\n`
|
||||
}
|
||||
|
||||
return `${contextPrefix}${selectedTextBlock}<USER_QUERY>\n${message}\n</USER_QUERY>`
|
||||
}
|
||||
|
||||
@@ -203,7 +203,28 @@ function getExecution(
|
||||
- Don't ask permission for routine steps. Act, then report.
|
||||
- Do not refuse by default, attempt tasks even when outcomes are uncertain.
|
||||
- For ambiguous/unclear requests, ask one targeted clarifying question.
|
||||
- Stay on the current page. Only open new tabs when the user explicitly asks.
|
||||
- Stay on the current page for single-page tasks. Use \`navigate_page\` to move within one tab.
|
||||
|
||||
### Multi-tab workflow
|
||||
When a task requires working on multiple pages simultaneously:
|
||||
1. **Inform the user** that you're creating background tabs for the task.
|
||||
2. **Open new tabs in background** using \`new_page\` (opens in background by default) — never steal focus from the user's current tab.
|
||||
3. **IMMEDIATELY create a tab group** using \`group_tabs\` with a descriptive title — do this right after opening the tabs, before any other work. Include the user's current tab in the group. Every multi-tab task MUST have a tab group.
|
||||
4. **Work on background tabs** — all tools (click, fill, navigate, snapshot) work on background tabs via their page ID.
|
||||
5. **Narrate progress in chat** — keep the user informed: "Checking Vercel pricing... Now checking Netlify..."
|
||||
6. **Report results in chat** — summarize findings so the user doesn't need to switch tabs. Leave tabs open for the user to browse later.
|
||||
7. **Never force-switch the user's active tab.** If you need user interaction on a background tab (e.g., login, CAPTCHA), tell the user which tab needs attention and let them switch manually.
|
||||
8. **Never navigate the user's current tab** during a multi-tab task. The current tab is the user's anchor — use it only for reading (snapshots, content extraction). All navigation should happen on background tabs.
|
||||
|
||||
**Do NOT use \`create_hidden_window\` or \`new_hidden_page\` for user-requested tasks.** Hidden windows are invisible to the user and cannot be screenshotted. Use \`new_page\` (background mode) instead — tabs appear in the user's tab strip and can be inspected. Reserve hidden windows for automated/scheduled runs only.
|
||||
|
||||
For single-page lookups (e.g., "go to X and read Y"), use \`navigate_page\` on the current tab. Only create new tabs when the task requires multiple pages open simultaneously.
|
||||
|
||||
### Tab retry discipline
|
||||
When a background tab fails (404, wrong content, unexpected redirect):
|
||||
- **Navigate the existing tab** to the correct URL with \`navigate_page\` — do NOT open a new tab for retries.
|
||||
- If you must abandon a tab, close it with \`close_page\` before opening a replacement.
|
||||
- Never let orphan tabs accumulate — each task should end with only the tabs that contain useful content.
|
||||
|
||||
### Observe → Act → Verify
|
||||
- **Before acting**: Take a snapshot to get interactive element IDs.
|
||||
@@ -247,6 +268,14 @@ function getToolSelection(): string {
|
||||
- Prefer \`fill\` over \`press_key\` for text input. Use \`press_key\` for keyboard shortcuts (Enter, Escape, Tab, Ctrl+A, etc.).
|
||||
- Prefer clicking links over \`navigate_page\` when the link is visible. Use \`navigate_page\` for direct URL access, back/forward, or reload.
|
||||
|
||||
### Navigation: single-tab vs multi-tab
|
||||
| Task | Approach |
|
||||
|------|----------|
|
||||
| Look up one page | \`navigate_page\` on current tab |
|
||||
| Research across multiple sites | \`new_page\` (background) for each site + \`group_tabs\` |
|
||||
| Compare two pages side by side | \`new_page\` (background) × 2 + \`group_tabs\` |
|
||||
| User says "open a new tab" | \`new_page\` (background) — don't steal focus |
|
||||
|
||||
### Connected apps: Strata vs browser
|
||||
When an app is Connected, prefer Strata tools over browser automation. Strata is faster, more reliable, and works without navigating away from the user's current page.
|
||||
</tool_selection>`
|
||||
@@ -351,7 +380,12 @@ function getErrorRecovery(
|
||||
### Strata errors
|
||||
- Authentication error → call \`suggest_app_connection\` for re-auth (STOP and wait)
|
||||
- Action not found → try \`search_documentation\`, then fall back to browser automation
|
||||
- Partial failure → report what succeeded and what didn't`
|
||||
- Partial failure → report what succeeded and what didn't
|
||||
|
||||
### Retry budget
|
||||
- If a site isn't cooperating after 3-4 attempts (form not filling, redirects, geo-blocks), stop trying.
|
||||
- Report what you've found so far and explain what didn't work: "Kayak kept defaulting to your local city. Here are the Google Flights results instead."
|
||||
- Don't exhaust 10+ tool calls on a single failing site — the user's time matters more than completeness.`
|
||||
|
||||
if (hasWorkspace) {
|
||||
recovery += `
|
||||
@@ -388,7 +422,7 @@ function getMemoryAndIdentity(
|
||||
section += `
|
||||
|
||||
### Your Personality (SOUL.md)
|
||||
${options?.soulContent ? options.soulContent + '\n' : ''}SOUL.md defines **how you behave** — your personality, tone, communication style, rules, and boundaries. Update it with \`soul_update\` when you learn how the user wants you to act. Use \`soul_read\` to read the current SOUL.md before updating.
|
||||
${options?.soulContent ? `${options.soulContent}\n` : ''}SOUL.md defines **how you behave** — your personality, tone, communication style, rules, and boundaries. Update it with \`soul_update\` when you learn how the user wants you to act. Use \`soul_read\` to read the current SOUL.md before updating.
|
||||
**SOUL.md is NOT for storing facts about the user.** User facts belong in core memory via \`memory_save_core\`.`
|
||||
|
||||
// Soul bootstrap
|
||||
@@ -415,8 +449,10 @@ You remember things across sessions using two tiers:
|
||||
**Core memory** (\`CORE.md\`) — permanent facts about the user that persist forever.
|
||||
Use for: name, job, location, preferences, relationships, recurring projects, important dates.
|
||||
- \`memory_read_core\` → read all permanent facts
|
||||
- \`memory_save_core\` → save permanent facts
|
||||
**IMPORTANT**: \`memory_save_core\` overwrites the entire file. Always call \`memory_read_core\` first, merge new facts into existing content, then save the full result.
|
||||
- \`memory_update_core\` → add or remove facts from core memory
|
||||
Pass \`additions\` (array of new facts) and/or \`removals\` (array of facts to remove by substring match).
|
||||
This tool handles merging internally — you never need to rewrite the full file.
|
||||
Do NOT use \`memory_save_core\` — it is deprecated and risks overwriting all existing memories.
|
||||
|
||||
**Daily memory** — short-lived notes stored in daily files (\`YYYY-MM-DD.md\`). Auto-expire after 30 days.
|
||||
Use for: what the user worked on today, transient context, meeting notes, draft ideas, things to follow up on.
|
||||
@@ -526,6 +562,12 @@ Default: do not narrate routine, low-risk tool calls (just call the tool).
|
||||
Narrate only when it helps: multi-step plans, complex navigation, or when the user explicitly asked for explanation.
|
||||
Keep narration brief. "Searching for flights..." then tool call — not "I will now search for flights by calling the search tool."
|
||||
Execute independent tool calls in parallel when possible.
|
||||
|
||||
When working on background tabs, always narrate progress so the user knows what's happening:
|
||||
- "Opening a background tab to check Yahoo News headlines..."
|
||||
- "Found 5 headlines on Yahoo News. Now checking Reuters..."
|
||||
- "Done! Here's what I found across all sources:"
|
||||
This is essential because the user can't see the background tabs — chat is their only window into your work.
|
||||
</tool_call_style>
|
||||
|
||||
- Be concise: 1-2 lines for status updates and action confirmations.
|
||||
|
||||
@@ -4,9 +4,13 @@ import { createAzure } from '@ai-sdk/azure'
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google'
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import type { LanguageModel } from 'ai'
|
||||
import { createBrowserOSFetch } from '../lib/browseros-fetch'
|
||||
import { createCodexFetch } from '../lib/clients/oauth/codex-fetch'
|
||||
import { createCopilotFetch } from '../lib/clients/oauth/copilot-fetch'
|
||||
import { logger } from '../lib/logger'
|
||||
import { createOpenRouterCompatibleFetch } from '../lib/openrouter-fetch'
|
||||
import type { ResolvedAgentConfig } from './types'
|
||||
@@ -101,26 +105,38 @@ function createBrowserOSFactory(
|
||||
config: ResolvedAgentConfig,
|
||||
): (modelId: string) => unknown {
|
||||
if (!config.baseUrl) throw new Error('BrowserOS provider requires baseUrl')
|
||||
const { baseUrl, apiKey, upstreamProvider } = config
|
||||
const { baseUrl, apiKey, upstreamProvider, browserosId } = config
|
||||
const browserosFetch = browserosId
|
||||
? createBrowserOSFetch(browserosId)
|
||||
: createOpenRouterCompatibleFetch()
|
||||
|
||||
if (upstreamProvider === LLM_PROVIDERS.OPENROUTER) {
|
||||
return createOpenRouter({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: createOpenRouterCompatibleFetch(),
|
||||
fetch: browserosFetch,
|
||||
})
|
||||
}
|
||||
if (upstreamProvider === LLM_PROVIDERS.ANTHROPIC) {
|
||||
return createAnthropic({ baseURL: baseUrl, ...(apiKey && { apiKey }) })
|
||||
return createAnthropic({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})
|
||||
}
|
||||
if (upstreamProvider === LLM_PROVIDERS.AZURE) {
|
||||
return createAzure({ baseURL: baseUrl, ...(apiKey && { apiKey }) })
|
||||
return createAzure({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})
|
||||
}
|
||||
logger.info('creating openai-compatible')
|
||||
logger.debug('Creating OpenAI-compatible provider for BrowserOS')
|
||||
return createOpenAICompatible({
|
||||
name: 'browseros',
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,6 +164,41 @@ function createMoonshotFactory(
|
||||
})
|
||||
}
|
||||
|
||||
function createQwenCodeFactory(
|
||||
config: ResolvedAgentConfig,
|
||||
): (modelId: string) => unknown {
|
||||
if (!config.apiKey) throw new Error('Qwen Code requires OAuth authentication')
|
||||
return createOpenAICompatible({
|
||||
name: 'qwen-code',
|
||||
baseURL: EXTERNAL_URLS.QWEN_CODE_API,
|
||||
apiKey: config.apiKey,
|
||||
})
|
||||
}
|
||||
|
||||
function createGitHubCopilotFactory(
|
||||
config: ResolvedAgentConfig,
|
||||
): (modelId: string) => unknown {
|
||||
if (!config.apiKey)
|
||||
throw new Error('GitHub Copilot requires OAuth authentication')
|
||||
return createOpenAICompatible({
|
||||
name: 'github-copilot',
|
||||
baseURL: EXTERNAL_URLS.GITHUB_COPILOT_API,
|
||||
apiKey: config.apiKey,
|
||||
fetch: createCopilotFetch() as typeof globalThis.fetch,
|
||||
})
|
||||
}
|
||||
|
||||
function createChatGPTProFactory(
|
||||
config: ResolvedAgentConfig,
|
||||
): (modelId: string) => unknown {
|
||||
if (!config.apiKey)
|
||||
throw new Error('ChatGPT Plus/Pro requires OAuth authentication')
|
||||
return createOpenAI({
|
||||
apiKey: config.apiKey,
|
||||
fetch: createCodexFetch(config.accountId) as typeof globalThis.fetch,
|
||||
}).responses
|
||||
}
|
||||
|
||||
const PROVIDER_FACTORIES: Record<string, ProviderFactory> = {
|
||||
[LLM_PROVIDERS.ANTHROPIC]: createAnthropicFactory,
|
||||
[LLM_PROVIDERS.OPENAI]: createOpenAIFactory,
|
||||
@@ -160,6 +211,9 @@ const PROVIDER_FACTORIES: Record<string, ProviderFactory> = {
|
||||
[LLM_PROVIDERS.BROWSEROS]: createBrowserOSFactory,
|
||||
[LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleFactory,
|
||||
[LLM_PROVIDERS.MOONSHOT]: createMoonshotFactory,
|
||||
[LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProFactory,
|
||||
[LLM_PROVIDERS.GITHUB_COPILOT]: createGitHubCopilotFactory,
|
||||
[LLM_PROVIDERS.QWEN_CODE]: createQwenCodeFactory,
|
||||
}
|
||||
|
||||
export function createLanguageModel(
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface ResolvedAgentConfig {
|
||||
accessKeyId?: string
|
||||
secretAccessKey?: string
|
||||
sessionToken?: string
|
||||
accountId?: string
|
||||
reasoningEffort?: string
|
||||
reasoningSummary?: string
|
||||
contextWindowSize?: number
|
||||
userSystemPrompt?: string
|
||||
workingDir: string
|
||||
@@ -43,4 +46,6 @@ export interface ResolvedAgentConfig {
|
||||
isScheduledTask?: boolean
|
||||
/** Apps the user previously declined to connect via MCP (chose "do it manually"). */
|
||||
declinedApps?: string[]
|
||||
/** BrowserOS installation ID for credit-based tracking. */
|
||||
browserosId?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { fetchCredits } from '../../lib/clients/gateway'
|
||||
import { logger } from '../../lib/logger'
|
||||
|
||||
interface CreditsDeps {
|
||||
browserosId?: string
|
||||
gatewayBaseUrl?: string
|
||||
}
|
||||
|
||||
export function createCreditsRoutes(deps: CreditsDeps) {
|
||||
const { browserosId, gatewayBaseUrl } = deps
|
||||
|
||||
if (!browserosId || !gatewayBaseUrl) {
|
||||
return new Hono().all('/*', (c) =>
|
||||
c.json({ error: 'Credits not configured' }, 503),
|
||||
)
|
||||
}
|
||||
|
||||
return new Hono().get('/', async (c) => {
|
||||
try {
|
||||
const credits = await fetchCredits(gatewayBaseUrl, browserosId)
|
||||
return c.json(credits)
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch credits', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return c.json({ error: 'Failed to fetch credits' }, 502)
|
||||
}
|
||||
})
|
||||
}
|
||||
100
packages/browseros-agent/apps/server/src/api/routes/oauth.ts
Normal file
100
packages/browseros-agent/apps/server/src/api/routes/oauth.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* OAuth routes for subscription-based LLM provider authentication.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { getOAuthProvider } from '../../lib/clients/oauth/providers'
|
||||
import type { OAuthTokenManager } from '../../lib/clients/oauth/token-manager'
|
||||
import { logger } from '../../lib/logger'
|
||||
|
||||
interface OAuthRouteDeps {
|
||||
tokenManager: OAuthTokenManager
|
||||
}
|
||||
|
||||
export function createOAuthRoutes(deps: OAuthRouteDeps) {
|
||||
const { tokenManager } = deps
|
||||
|
||||
return new Hono()
|
||||
.get('/:provider/start', async (c) => {
|
||||
const providerId = c.req.param('provider')
|
||||
const redirectBackUrl = c.req.query('redirect')
|
||||
|
||||
const provider = getOAuthProvider(providerId)
|
||||
if (!provider) {
|
||||
return c.text(`Unknown OAuth provider: ${providerId}`, 400)
|
||||
}
|
||||
|
||||
try {
|
||||
// Device Code flow: return JSON with user code for the extension to display
|
||||
if (provider.authFlow === 'device-code') {
|
||||
const result = await tokenManager.startDeviceCodeFlow(providerId)
|
||||
return c.json({
|
||||
userCode: result.userCode,
|
||||
verificationUri: result.verificationUri,
|
||||
expiresIn: result.expiresIn,
|
||||
})
|
||||
}
|
||||
|
||||
// PKCE flow: redirect to auth server
|
||||
const authUrl = await tokenManager.generateAuthorizationUrl(
|
||||
providerId,
|
||||
redirectBackUrl,
|
||||
)
|
||||
return c.redirect(authUrl)
|
||||
} catch (error) {
|
||||
logger.error('Failed to start OAuth flow', {
|
||||
provider: providerId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to start authentication. Please try again.'
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/:provider/token', async (c) => {
|
||||
const providerId = c.req.param('provider')
|
||||
const provider = getOAuthProvider(providerId)
|
||||
if (!provider) return c.text(`Unknown OAuth provider: ${providerId}`, 400)
|
||||
|
||||
try {
|
||||
const body = await c.req.json()
|
||||
if (!body.accessToken) return c.text('Missing accessToken', 400)
|
||||
|
||||
tokenManager.storeTokens(providerId, {
|
||||
accessToken: body.accessToken,
|
||||
refreshToken: body.refreshToken ?? '',
|
||||
expiresIn: body.expiresIn ?? 0,
|
||||
})
|
||||
logger.info('OAuth tokens stored from client', {
|
||||
provider: providerId,
|
||||
})
|
||||
return c.json({ ok: true })
|
||||
} catch (error) {
|
||||
logger.error('Failed to store OAuth token', {
|
||||
provider: providerId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return c.text('Failed to store token', 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/:provider/status', (c) => {
|
||||
const providerId = c.req.param('provider')
|
||||
const status = tokenManager.getStatus(providerId)
|
||||
return c.json(status)
|
||||
})
|
||||
|
||||
.delete('/:provider', (c) => {
|
||||
const providerId = c.req.param('provider')
|
||||
tokenManager.deleteTokens(providerId)
|
||||
logger.info('OAuth tokens deleted', { provider: providerId })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,11 @@ import { testProviderConnection } from '../../lib/clients/llm/test-provider'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { AgentLLMConfigSchema } from '../types'
|
||||
|
||||
export function createProviderRoutes() {
|
||||
interface ProviderRouteDeps {
|
||||
browserosId?: string
|
||||
}
|
||||
|
||||
export function createProviderRoutes(deps: ProviderRouteDeps = {}) {
|
||||
return new Hono().post(
|
||||
'/',
|
||||
zValidator('json', AgentLLMConfigSchema),
|
||||
@@ -22,7 +26,7 @@ export function createProviderRoutes() {
|
||||
model: config.model,
|
||||
})
|
||||
|
||||
const result = await testProviderConnection(config)
|
||||
const result = await testProviderConnection(config, deps.browserosId)
|
||||
|
||||
logger.info('Provider test result', {
|
||||
provider: config.provider,
|
||||
|
||||
@@ -10,7 +10,11 @@ const RefinePromptRequestSchema = AgentLLMConfigSchema.extend({
|
||||
name: z.string().min(1, 'Task name cannot be empty'),
|
||||
})
|
||||
|
||||
export function createRefinePromptRoutes() {
|
||||
interface RefinePromptRouteDeps {
|
||||
browserosId?: string
|
||||
}
|
||||
|
||||
export function createRefinePromptRoutes(deps: RefinePromptRouteDeps = {}) {
|
||||
return new Hono().post(
|
||||
'/',
|
||||
zValidator('json', RefinePromptRequestSchema),
|
||||
@@ -23,7 +27,11 @@ export function createRefinePromptRoutes() {
|
||||
taskName: name,
|
||||
})
|
||||
|
||||
const result = await refinePrompt(llmConfig, { prompt, name })
|
||||
const result = await refinePrompt(
|
||||
llmConfig,
|
||||
{ prompt, name },
|
||||
deps.browserosId,
|
||||
)
|
||||
|
||||
logger.info('Refine prompt result', {
|
||||
provider: llmConfig.provider,
|
||||
|
||||
@@ -58,7 +58,11 @@ export function createSkillsRoutes() {
|
||||
return c.json({ ok: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to delete'
|
||||
const status = msg.includes('not found') ? 404 : 500
|
||||
const status = msg.includes('not found')
|
||||
? 404
|
||||
: msg.includes('built-in')
|
||||
? 403
|
||||
: 500
|
||||
return c.json({ error: msg }, status)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,14 +14,19 @@ import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
import { HttpAgentError } from '../agent/errors'
|
||||
import { INLINED_ENV } from '../env'
|
||||
import { KlavisClient } from '../lib/clients/klavis/klavis-client'
|
||||
import { initializeOAuth } from '../lib/clients/oauth'
|
||||
import { getDb } from '../lib/db'
|
||||
import { logger } from '../lib/logger'
|
||||
import { createChatRoutes } from './routes/chat'
|
||||
import { createCreditsRoutes } from './routes/credits'
|
||||
import { createGraphRoutes } from './routes/graph'
|
||||
import { createHealthRoute } from './routes/health'
|
||||
import { createKlavisRoutes } from './routes/klavis'
|
||||
import { createMcpRoutes } from './routes/mcp'
|
||||
import { createMemoryRoutes } from './routes/memory'
|
||||
import { createOAuthRoutes } from './routes/oauth'
|
||||
import { createProviderRoutes } from './routes/provider'
|
||||
import { createRefinePromptRoutes } from './routes/refine-prompt'
|
||||
import { createSdkRoutes } from './routes/sdk'
|
||||
@@ -75,6 +80,11 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
|
||||
const { onShutdown } = config
|
||||
|
||||
// Initialize OAuth token manager + callback server (port released on process exit)
|
||||
const tokenManager = browserosId
|
||||
? initializeOAuth(getDb(), browserosId)
|
||||
: null
|
||||
|
||||
// Connect Klavis proxy (non-blocking: browser tools still work if this fails)
|
||||
let klavisProxy: KlavisProxyHandle | null = null
|
||||
if (browserosId) {
|
||||
@@ -113,9 +123,26 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.route('/soul', createSoulRoutes())
|
||||
.route('/memory', createMemoryRoutes())
|
||||
.route('/skills', createSkillsRoutes())
|
||||
.route('/test-provider', createProviderRoutes())
|
||||
.route('/refine-prompt', createRefinePromptRoutes())
|
||||
.route('/test-provider', createProviderRoutes({ browserosId }))
|
||||
.route('/refine-prompt', createRefinePromptRoutes({ browserosId }))
|
||||
.route(
|
||||
'/oauth',
|
||||
tokenManager
|
||||
? createOAuthRoutes({ tokenManager })
|
||||
: new Hono().all('/*', (c) =>
|
||||
c.json({ error: 'OAuth not available' }, 503),
|
||||
),
|
||||
)
|
||||
.route('/klavis', createKlavisRoutes({ browserosId: browserosId || '' }))
|
||||
.route(
|
||||
'/credits',
|
||||
createCreditsRoutes({
|
||||
browserosId,
|
||||
gatewayBaseUrl: INLINED_ENV.BROWSEROS_CONFIG_URL
|
||||
? new URL(INLINED_ENV.BROWSEROS_CONFIG_URL).origin
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
'/mcp',
|
||||
createMcpRoutes({
|
||||
|
||||
@@ -54,6 +54,9 @@ export class ChatService {
|
||||
accessKeyId: llmConfig.accessKeyId,
|
||||
secretAccessKey: llmConfig.secretAccessKey,
|
||||
sessionToken: llmConfig.sessionToken,
|
||||
accountId: llmConfig.accountId,
|
||||
reasoningEffort: request.reasoningEffort,
|
||||
reasoningSummary: request.reasoningSummary,
|
||||
contextWindowSize: request.contextWindowSize,
|
||||
userSystemPrompt: request.userSystemPrompt,
|
||||
workingDir,
|
||||
@@ -61,6 +64,7 @@ export class ChatService {
|
||||
chatMode: request.mode === 'chat',
|
||||
isScheduledTask: request.isScheduledTask,
|
||||
declinedApps: request.declinedApps,
|
||||
browserosId: this.deps.browserosId,
|
||||
}
|
||||
|
||||
let session = sessionStore.get(request.conversationId)
|
||||
@@ -168,6 +172,8 @@ export class ChatService {
|
||||
const userContent = formatUserMessage(
|
||||
request.message,
|
||||
resolvedMessageContext,
|
||||
request.selectedText,
|
||||
request.selectedTextSource,
|
||||
)
|
||||
session.agent.appendUserMessage(userContent)
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ import type { ToolRegistry } from '../tools/tool-registry'
|
||||
|
||||
// Re-export browser context types for consumers
|
||||
export {
|
||||
BrowserContextSchema,
|
||||
CustomMcpServerSchema,
|
||||
TabSchema,
|
||||
type BrowserContext,
|
||||
BrowserContextSchema,
|
||||
type CustomMcpServer,
|
||||
CustomMcpServerSchema,
|
||||
type Tab,
|
||||
TabSchema,
|
||||
}
|
||||
|
||||
export const AgentLLMConfigSchema = LLMConfigSchema.extend({
|
||||
@@ -47,6 +47,13 @@ export const ChatRequestSchema = AgentLLMConfigSchema.extend({
|
||||
supportsImages: z.boolean().optional().default(true),
|
||||
mode: z.enum(['chat', 'agent']).optional().default('agent'),
|
||||
declinedApps: z.array(z.string()).optional(),
|
||||
selectedText: z.string().optional(),
|
||||
selectedTextSource: z
|
||||
.object({
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
previousConversation: z
|
||||
.union([
|
||||
z.array(
|
||||
|
||||
@@ -823,14 +823,7 @@ export class Browser {
|
||||
if (coords) {
|
||||
const value = await elements.getInputValue(session, element)
|
||||
if (value) {
|
||||
await mouse.dispatchClick(
|
||||
session,
|
||||
coords.x,
|
||||
coords.y,
|
||||
'left',
|
||||
3,
|
||||
0,
|
||||
)
|
||||
await mouse.dispatchClick(session, coords.x, coords.y, 'left', 3, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,14 @@ export function getSkillsDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.SKILLS_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getBuiltinSkillsDir(): string {
|
||||
return join(getSkillsDir(), PATHS.BUILTIN_DIR_NAME)
|
||||
}
|
||||
|
||||
export async function ensureBrowserosDir(): Promise<void> {
|
||||
await mkdir(getMemoryDir(), { recursive: true })
|
||||
await mkdir(getSkillsDir(), { recursive: true })
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
await mkdir(getSessionsDir(), { recursive: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Custom fetch for BrowserOS gateway requests.
|
||||
* Adds X-BrowserOS-ID header for credit tracking,
|
||||
* handles CREDITS_EXHAUSTED (429), and extracts OpenRouter-style error details.
|
||||
*/
|
||||
|
||||
import { APICallError } from '@ai-sdk/provider'
|
||||
import { logger } from './logger'
|
||||
|
||||
function resolveUrl(url: RequestInfo | URL): string {
|
||||
return typeof url === 'string' ? url : url.toString()
|
||||
}
|
||||
|
||||
function parseErrorBody(
|
||||
body: string,
|
||||
): { message?: string; code?: string; metadata?: { raw?: unknown } } | null {
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
return parsed.error ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildErrorMessage(
|
||||
statusCode: number,
|
||||
statusText: string,
|
||||
error: NonNullable<ReturnType<typeof parseErrorBody>>,
|
||||
): string {
|
||||
if (!error.message) return `HTTP ${statusCode}: ${statusText}`
|
||||
let msg = error.message
|
||||
if (error.code) msg = `[${error.code}] ${msg}`
|
||||
if (error.metadata?.raw) msg += ` (${JSON.stringify(error.metadata.raw)})`
|
||||
return msg
|
||||
}
|
||||
|
||||
export function createBrowserOSFetch(browserosId: string): typeof fetch {
|
||||
return (async (url: RequestInfo | URL, options?: RequestInit) => {
|
||||
const headers = new Headers(options?.headers)
|
||||
headers.set('X-BrowserOS-ID', browserosId)
|
||||
|
||||
const response = await globalThis.fetch(url, { ...options, headers })
|
||||
|
||||
const creditsRemaining = response.headers.get('X-Credits-Remaining')
|
||||
if (creditsRemaining !== null) {
|
||||
logger.debug('Credits remaining', { creditsRemaining })
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const statusCode = response.status
|
||||
const responseBody = await response.text()
|
||||
const error = parseErrorBody(responseBody)
|
||||
|
||||
if (statusCode === 429 && error?.code === 'CREDITS_EXHAUSTED') {
|
||||
throw new APICallError({
|
||||
message: error.message ?? 'Daily credits exhausted',
|
||||
url: resolveUrl(url),
|
||||
requestBodyValues: {},
|
||||
statusCode,
|
||||
responseBody,
|
||||
isRetryable: false,
|
||||
})
|
||||
}
|
||||
|
||||
throw new APICallError({
|
||||
message: error
|
||||
? buildErrorMessage(statusCode, response.statusText, error)
|
||||
: `HTTP ${statusCode}: ${response.statusText}`,
|
||||
url: resolveUrl(url),
|
||||
requestBodyValues: {},
|
||||
statusCode,
|
||||
responseBody,
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
}) as typeof fetch
|
||||
}
|
||||
@@ -11,9 +11,18 @@ export interface Provider {
|
||||
apiKey: string
|
||||
baseUrl?: string
|
||||
dailyRateLimit?: number
|
||||
dailyCredits?: number
|
||||
creditCostPerRequest?: number
|
||||
resetInterval?: string
|
||||
providerType?: string // LLMProvider value from ai-gateway: "openrouter" | "azure" | "anthropic"
|
||||
}
|
||||
|
||||
export interface CreditsInfo {
|
||||
credits: number
|
||||
dailyLimit: number
|
||||
lastResetAt?: string
|
||||
}
|
||||
|
||||
export interface BrowserOSConfig {
|
||||
providers: Provider[]
|
||||
}
|
||||
@@ -109,3 +118,20 @@ export function getLLMConfigFromProvider(
|
||||
providerType: provider.providerType,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCredits(
|
||||
gatewayBaseUrl: string,
|
||||
browserosId: string,
|
||||
): Promise<CreditsInfo> {
|
||||
const url = new URL(`/credits/${browserosId}`, gatewayBaseUrl).href
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(
|
||||
`Failed to fetch credits: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
)
|
||||
}
|
||||
const result = (await response.json()) as CreditsInfo
|
||||
logger.debug('Credits fetched', { credits: result.credits })
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -10,19 +10,97 @@ import { LLM_PROVIDERS, type LLMConfig } from '@browseros/shared/schemas/llm'
|
||||
import { INLINED_ENV } from '../../../env'
|
||||
import { logger } from '../../logger'
|
||||
import { fetchBrowserOSConfig, getLLMConfigFromProvider } from '../gateway'
|
||||
import { getOAuthTokenManager } from '../oauth'
|
||||
import type { ResolvedLLMConfig } from './types'
|
||||
|
||||
export async function resolveLLMConfig(
|
||||
config: LLMConfig,
|
||||
browserosId?: string,
|
||||
): Promise<ResolvedLLMConfig> {
|
||||
if (config.provider !== LLM_PROVIDERS.BROWSEROS) {
|
||||
if (!config.model) {
|
||||
throw new Error(`model is required for ${config.provider} provider`)
|
||||
}
|
||||
return config as ResolvedLLMConfig
|
||||
// OAuth providers: resolve token from server-side storage
|
||||
if (config.provider === LLM_PROVIDERS.CHATGPT_PRO) {
|
||||
return resolveOAuthConfig(config, browserosId, {
|
||||
providerId: 'chatgpt-pro',
|
||||
displayName: 'ChatGPT Plus/Pro',
|
||||
defaultModel: 'gpt-5.3-codex',
|
||||
useRefresh: true,
|
||||
extraFields: (tokens) => ({
|
||||
upstreamProvider: 'openai',
|
||||
accountId: tokens.accountId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (config.provider === LLM_PROVIDERS.GITHUB_COPILOT) {
|
||||
return resolveOAuthConfig(config, browserosId, {
|
||||
providerId: 'github-copilot',
|
||||
displayName: 'GitHub Copilot',
|
||||
defaultModel: 'gpt-5-mini',
|
||||
useRefresh: false,
|
||||
})
|
||||
}
|
||||
if (config.provider === LLM_PROVIDERS.QWEN_CODE) {
|
||||
return resolveOAuthConfig(config, browserosId, {
|
||||
providerId: 'qwen-code',
|
||||
displayName: 'Qwen Code',
|
||||
defaultModel: 'coder-model',
|
||||
useRefresh: true,
|
||||
})
|
||||
}
|
||||
|
||||
// BrowserOS gateway: fetch config from remote service
|
||||
if (config.provider === LLM_PROVIDERS.BROWSEROS) {
|
||||
return resolveBrowserOSConfig(config, browserosId)
|
||||
}
|
||||
|
||||
// All other providers: passthrough with model validation
|
||||
if (!config.model) {
|
||||
throw new Error(`model is required for ${config.provider} provider`)
|
||||
}
|
||||
return config as ResolvedLLMConfig
|
||||
}
|
||||
|
||||
interface OAuthResolveOptions {
|
||||
providerId: string
|
||||
displayName: string
|
||||
defaultModel: string
|
||||
useRefresh: boolean
|
||||
extraFields?: (tokens: { accountId?: string }) => Record<string, unknown>
|
||||
}
|
||||
|
||||
async function resolveOAuthConfig(
|
||||
config: LLMConfig,
|
||||
browserosId: string | undefined,
|
||||
opts: OAuthResolveOptions,
|
||||
): Promise<ResolvedLLMConfig> {
|
||||
const tokenManager = getOAuthTokenManager()
|
||||
if (!tokenManager || !browserosId) {
|
||||
throw new Error(
|
||||
`Not authenticated with ${opts.displayName}. Please login first.`,
|
||||
)
|
||||
}
|
||||
|
||||
const tokens = opts.useRefresh
|
||||
? await tokenManager.refreshIfExpired(opts.providerId)
|
||||
: tokenManager.getTokens(opts.providerId)
|
||||
|
||||
if (!tokens) {
|
||||
throw new Error(
|
||||
`Not authenticated with ${opts.displayName}. Please login first.`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
model: config.model || opts.defaultModel,
|
||||
apiKey: tokens.accessToken,
|
||||
...opts.extraFields?.(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBrowserOSConfig(
|
||||
config: LLMConfig,
|
||||
browserosId?: string,
|
||||
): Promise<ResolvedLLMConfig> {
|
||||
const configUrl = INLINED_ENV.BROWSEROS_CONFIG_URL
|
||||
if (!configUrl) {
|
||||
throw new Error(
|
||||
@@ -41,5 +119,6 @@ export async function resolveLLMConfig(
|
||||
apiKey: llmConfig.apiKey,
|
||||
baseUrl: llmConfig.baseUrl,
|
||||
upstreamProvider: llmConfig.providerType,
|
||||
browserosId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@ import { createAzure } from '@ai-sdk/azure'
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google'
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import type { LanguageModel } from 'ai'
|
||||
import { createBrowserOSFetch } from '../../browseros-fetch'
|
||||
import { logger } from '../../logger'
|
||||
import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch'
|
||||
import { createCodexFetch } from '../oauth/codex-fetch'
|
||||
import { createCopilotFetch } from '../oauth/copilot-fetch'
|
||||
import type { ResolvedLLMConfig } from './types'
|
||||
|
||||
type ProviderFactory = (config: ResolvedLLMConfig) => LanguageModel
|
||||
@@ -89,28 +93,38 @@ function createBedrockModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
|
||||
function createBrowserOSModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
if (!config.baseUrl) throw new Error('BrowserOS provider requires baseUrl')
|
||||
const { baseUrl, apiKey, model, upstreamProvider } = config
|
||||
const { baseUrl, apiKey, model, upstreamProvider, browserosId } = config
|
||||
const browserosFetch = browserosId
|
||||
? createBrowserOSFetch(browserosId)
|
||||
: createOpenRouterCompatibleFetch()
|
||||
|
||||
if (upstreamProvider === LLM_PROVIDERS.OPENROUTER) {
|
||||
return createOpenRouter({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: createOpenRouterCompatibleFetch(),
|
||||
fetch: browserosFetch,
|
||||
})(model)
|
||||
}
|
||||
if (upstreamProvider === LLM_PROVIDERS.ANTHROPIC) {
|
||||
return createAnthropic({ baseURL: baseUrl, ...(apiKey && { apiKey }) })(
|
||||
model,
|
||||
)
|
||||
return createAnthropic({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})(model)
|
||||
}
|
||||
if (upstreamProvider === LLM_PROVIDERS.AZURE) {
|
||||
return createAzure({ baseURL: baseUrl, ...(apiKey && { apiKey }) })(model)
|
||||
return createAzure({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})(model)
|
||||
}
|
||||
logger.debug('Creating OpenAI-compatible provider for BrowserOS')
|
||||
return createOpenAICompatible({
|
||||
name: 'browseros',
|
||||
baseURL: baseUrl,
|
||||
...(apiKey && { apiKey }),
|
||||
fetch: browserosFetch,
|
||||
})(model)
|
||||
}
|
||||
|
||||
@@ -134,6 +148,35 @@ function createMoonshotModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
})(config.model)
|
||||
}
|
||||
|
||||
function createQwenCodeModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
if (!config.apiKey) throw new Error('Qwen Code requires OAuth authentication')
|
||||
return createOpenAICompatible({
|
||||
name: 'qwen-code',
|
||||
baseURL: EXTERNAL_URLS.QWEN_CODE_API,
|
||||
apiKey: config.apiKey,
|
||||
})(config.model)
|
||||
}
|
||||
|
||||
function createGitHubCopilotModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
if (!config.apiKey)
|
||||
throw new Error('GitHub Copilot requires OAuth authentication')
|
||||
return createOpenAICompatible({
|
||||
name: 'github-copilot',
|
||||
baseURL: EXTERNAL_URLS.GITHUB_COPILOT_API,
|
||||
apiKey: config.apiKey,
|
||||
fetch: createCopilotFetch() as typeof globalThis.fetch,
|
||||
})(config.model)
|
||||
}
|
||||
|
||||
function createChatGPTProModel(config: ResolvedLLMConfig): LanguageModel {
|
||||
if (!config.apiKey)
|
||||
throw new Error('ChatGPT Plus/Pro requires OAuth authentication')
|
||||
return createOpenAI({
|
||||
apiKey: config.apiKey,
|
||||
fetch: createCodexFetch(config.accountId) as typeof globalThis.fetch,
|
||||
}).responses(config.model)
|
||||
}
|
||||
|
||||
const PROVIDER_FACTORIES: Record<string, ProviderFactory> = {
|
||||
[LLM_PROVIDERS.ANTHROPIC]: createAnthropicModel,
|
||||
[LLM_PROVIDERS.OPENAI]: createOpenAIModel,
|
||||
@@ -146,6 +189,9 @@ const PROVIDER_FACTORIES: Record<string, ProviderFactory> = {
|
||||
[LLM_PROVIDERS.BROWSEROS]: createBrowserOSModel,
|
||||
[LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleModel,
|
||||
[LLM_PROVIDERS.MOONSHOT]: createMoonshotModel,
|
||||
[LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProModel,
|
||||
[LLM_PROVIDERS.GITHUB_COPILOT]: createGitHubCopilotModel,
|
||||
[LLM_PROVIDERS.QWEN_CODE]: createQwenCodeModel,
|
||||
}
|
||||
|
||||
export function createLLMProvider(config: ResolvedLLMConfig): LanguageModel {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
|
||||
import type { LLMConfig } from '@browseros/shared/schemas/llm'
|
||||
import { generateText } from 'ai'
|
||||
import { streamText } from 'ai'
|
||||
import { resolveLLMConfig } from './config'
|
||||
import { createLLMProvider } from './provider'
|
||||
|
||||
@@ -38,18 +38,21 @@ Write it as a natural instruction — like telling a capable assistant what to d
|
||||
export async function refinePrompt(
|
||||
llmConfig: RefinePromptConfig,
|
||||
request: RefinePromptRequest,
|
||||
browserosId?: string,
|
||||
): Promise<RefinePromptResult> {
|
||||
try {
|
||||
const resolvedConfig = await resolveLLMConfig(llmConfig)
|
||||
const resolvedConfig = await resolveLLMConfig(llmConfig, browserosId)
|
||||
const model = createLLMProvider(resolvedConfig)
|
||||
const response = await generateText({
|
||||
|
||||
// streamText works for all providers including Codex (which requires streaming)
|
||||
const stream = streamText({
|
||||
model,
|
||||
system: buildSystemPrompt(request.name),
|
||||
messages: [{ role: 'user', content: request.prompt }],
|
||||
abortSignal: AbortSignal.timeout(TIMEOUTS.REFINE_PROMPT),
|
||||
})
|
||||
const refined = (await stream.text)?.trim()
|
||||
|
||||
const refined = response.text?.trim()
|
||||
if (!refined) {
|
||||
return { success: false, message: 'Provider returned an empty response' }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
|
||||
import type { LLMConfig } from '@browseros/shared/schemas/llm'
|
||||
import { generateText } from 'ai'
|
||||
import { streamText } from 'ai'
|
||||
import { resolveLLMConfig } from './config'
|
||||
import { createLLMProvider } from './provider'
|
||||
|
||||
@@ -25,20 +25,22 @@ const TEST_PROMPT = "Respond with exactly: 'ok'"
|
||||
|
||||
export async function testProviderConnection(
|
||||
config: ProviderTestConfig,
|
||||
browserosId?: string,
|
||||
): Promise<ProviderTestResult> {
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
const resolvedConfig = await resolveLLMConfig(config)
|
||||
const resolvedConfig = await resolveLLMConfig(config, browserosId)
|
||||
const model = createLLMProvider(resolvedConfig)
|
||||
const response = await generateText({
|
||||
|
||||
// streamText works for all providers including Codex (which requires streaming)
|
||||
const stream = streamText({
|
||||
model,
|
||||
messages: [{ role: 'user', content: TEST_PROMPT }],
|
||||
abortSignal: AbortSignal.timeout(TIMEOUTS.TEST_PROVIDER),
|
||||
})
|
||||
|
||||
const text = await stream.text
|
||||
const responseTime = Math.round(performance.now() - startTime)
|
||||
const text = response.text
|
||||
|
||||
if (text) {
|
||||
const preview = text.length > 100 ? `${text.slice(0, 100)}...` : text
|
||||
|
||||
@@ -11,4 +11,6 @@ import type { LLMConfig } from '@browseros/shared/schemas/llm'
|
||||
export interface ResolvedLLMConfig extends LLMConfig {
|
||||
model: string
|
||||
upstreamProvider?: string
|
||||
browserosId?: string
|
||||
accountId?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Temporary HTTP server on port 1455 for OAuth callbacks.
|
||||
* OpenAI's OAuth requires redirect_uri to use this specific port
|
||||
* (matching the Codex CLI client ID registration).
|
||||
*/
|
||||
|
||||
import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports'
|
||||
import { logger } from '../../logger'
|
||||
import type { OAuthTokenManager } from './token-manager'
|
||||
|
||||
export function startOAuthCallbackServer(tokenManager: OAuthTokenManager): {
|
||||
stop: () => void
|
||||
} {
|
||||
const server = Bun.serve({
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: async (req) => {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname !== '/auth/callback') {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const error = url.searchParams.get('error')
|
||||
|
||||
if (error) {
|
||||
const description = url.searchParams.get('error_description') || error
|
||||
logger.warn('OAuth callback received error', { error, description })
|
||||
return htmlResponse(errorPage(description))
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return htmlResponse(errorPage('Missing authorization code or state'))
|
||||
}
|
||||
|
||||
try {
|
||||
await tokenManager.handleCallback(code, state)
|
||||
|
||||
// Always show success page — chrome-extension:// redirects are blocked by Chromium.
|
||||
// The extension polls /oauth/:provider/status and detects auth automatically.
|
||||
return htmlResponse(successPage())
|
||||
} catch (err) {
|
||||
logger.error('OAuth callback failed', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return htmlResponse(
|
||||
errorPage(
|
||||
err instanceof Error ? err.message : 'Authentication failed',
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('OAuth callback server started', { port: OAUTH_CALLBACK_PORT })
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
server.stop()
|
||||
logger.info('OAuth callback server stopped')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function htmlResponse(html: string): Response {
|
||||
return new Response(html, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
})
|
||||
}
|
||||
|
||||
function successPage(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><title>BrowserOS - Authentication Successful</title>
|
||||
<style>body{font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f8f9fa}
|
||||
.card{text-align:center;padding:2rem;background:white;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,0.1)}
|
||||
h1{color:#22c55e;font-size:1.5rem}p{color:#6b7280}</style></head>
|
||||
<body><div class="card"><h1>Authentication Successful</h1><p>You can close this tab and return to BrowserOS.</p></div></body></html>`
|
||||
}
|
||||
|
||||
function errorPage(message: string): string {
|
||||
const escaped = message
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><title>BrowserOS - Authentication Failed</title>
|
||||
<style>body{font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f8f9fa}
|
||||
.card{text-align:center;padding:2rem;background:white;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,0.1)}
|
||||
h1{color:#ef4444;font-size:1.5rem}p{color:#6b7280}</style></head>
|
||||
<body><div class="card"><h1>Authentication Failed</h1><p>${escaped}</p><p>Please close this tab and try again.</p></div></body></html>`
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { logger } from '../../logger'
|
||||
|
||||
const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses'
|
||||
|
||||
export function createCodexFetch(accountId?: string) {
|
||||
return (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
let inputUrl: string
|
||||
if (typeof input === 'string') {
|
||||
inputUrl = input
|
||||
} else if (input instanceof URL) {
|
||||
inputUrl = input.toString()
|
||||
} else if (input instanceof Request) {
|
||||
inputUrl = input.url
|
||||
} else {
|
||||
inputUrl = String(input)
|
||||
}
|
||||
|
||||
const parsed = new URL(inputUrl)
|
||||
const shouldRewrite =
|
||||
parsed.pathname.includes('/v1/responses') ||
|
||||
parsed.pathname.includes('/chat/completions')
|
||||
const url = shouldRewrite ? new URL(CODEX_API_ENDPOINT) : parsed
|
||||
|
||||
const headers = new Headers(init?.headers as HeadersInit)
|
||||
if (accountId) {
|
||||
headers.set('ChatGPT-Account-Id', accountId)
|
||||
}
|
||||
headers.set('originator', 'browseros')
|
||||
headers.set('OpenAI-Beta', 'responses=experimental')
|
||||
|
||||
let body = init?.body
|
||||
if (shouldRewrite && body && typeof body === 'string') {
|
||||
try {
|
||||
const json = JSON.parse(body)
|
||||
json.stream = true
|
||||
json.store = false
|
||||
delete json.previous_response_id
|
||||
delete json.temperature
|
||||
delete json.max_tokens
|
||||
delete json.max_output_tokens
|
||||
delete json.top_p
|
||||
if (!json.instructions) {
|
||||
json.instructions = 'You are a helpful assistant.'
|
||||
}
|
||||
// Strip item IDs — Codex doesn't persist items with store=false.
|
||||
// The SDK should already inline content (via providerOptions store=false),
|
||||
// but this is a safety net matching OpenCode's approach.
|
||||
if (Array.isArray(json.input)) {
|
||||
for (const item of json.input) {
|
||||
if ('id' in item) {
|
||||
delete item.id
|
||||
}
|
||||
}
|
||||
}
|
||||
body = JSON.stringify(json)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'Failed to inject Codex-required fields into request body',
|
||||
{
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, headers, body })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Custom fetch wrapper for GitHub Copilot API requests.
|
||||
* Injects required Copilot headers and resizes images following
|
||||
* VS Code's algorithm (max 2048px longest side, 768px shortest side).
|
||||
*/
|
||||
|
||||
import { Jimp } from 'jimp'
|
||||
import { logger } from '../../logger'
|
||||
|
||||
const MAX_LONG_SIDE = 2048
|
||||
const MAX_SHORT_SIDE = 768
|
||||
|
||||
export function createCopilotFetch() {
|
||||
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers as HeadersInit)
|
||||
|
||||
headers.set('Openai-Intent', 'conversation-edits')
|
||||
headers.set('x-initiator', 'user')
|
||||
|
||||
let body = init?.body
|
||||
if (body && typeof body === 'string') {
|
||||
try {
|
||||
const json = JSON.parse(body)
|
||||
if (hasImageContent(json)) {
|
||||
headers.set('Copilot-Vision-Request', 'true')
|
||||
await shrinkImages(json)
|
||||
body = JSON.stringify(json)
|
||||
}
|
||||
} catch {
|
||||
// Not JSON or resize failed, send as-is
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(input, { ...init, headers, body })
|
||||
}
|
||||
}
|
||||
|
||||
function hasImageContent(body: Record<string, unknown>): boolean {
|
||||
if (!Array.isArray(body.messages)) return false
|
||||
for (const msg of body.messages) {
|
||||
if (!Array.isArray(msg?.content)) continue
|
||||
for (const part of msg.content) {
|
||||
if (part?.type === 'image_url') return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Resize images following VS Code's algorithm for OpenAI vision token optimization
|
||||
async function shrinkImages(body: Record<string, unknown>): Promise<void> {
|
||||
if (!Array.isArray(body.messages)) return
|
||||
|
||||
for (const msg of body.messages) {
|
||||
if (!Array.isArray(msg?.content)) continue
|
||||
for (const part of msg.content) {
|
||||
if (part?.type !== 'image_url' || !part.image_url) continue
|
||||
|
||||
const url = part.image_url.url as string
|
||||
if (!url?.startsWith('data:')) continue
|
||||
|
||||
try {
|
||||
const resized = await resizeDataUrl(url)
|
||||
if (resized) part.image_url.url = resized
|
||||
} catch (err) {
|
||||
logger.warn('Failed to resize image for Copilot', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resizeDataUrl(dataUrl: string): Promise<string | null> {
|
||||
const commaIdx = dataUrl.indexOf(',')
|
||||
if (commaIdx === -1) return null
|
||||
|
||||
const base64Data = dataUrl.substring(commaIdx + 1)
|
||||
const buffer = Buffer.from(base64Data, 'base64')
|
||||
|
||||
const image = await Jimp.fromBuffer(buffer)
|
||||
const origWidth = image.width
|
||||
const origHeight = image.height
|
||||
if (!origWidth || !origHeight) return null
|
||||
|
||||
let width = origWidth
|
||||
let height = origHeight
|
||||
|
||||
// Skip if already within both limits (no resize step will fire)
|
||||
if (
|
||||
Math.max(width, height) <= MAX_LONG_SIDE &&
|
||||
Math.min(width, height) <= MAX_SHORT_SIDE
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Step 1: scale longest side to 2048
|
||||
if (width > MAX_LONG_SIDE || height > MAX_LONG_SIDE) {
|
||||
const scale = MAX_LONG_SIDE / Math.max(width, height)
|
||||
width = Math.round(width * scale)
|
||||
height = Math.round(height * scale)
|
||||
}
|
||||
|
||||
// Step 2: scale shortest side to 768
|
||||
const shortSide = Math.min(width, height)
|
||||
if (shortSide > MAX_SHORT_SIDE) {
|
||||
const scale = MAX_SHORT_SIDE / shortSide
|
||||
width = Math.round(width * scale)
|
||||
height = Math.round(height * scale)
|
||||
}
|
||||
|
||||
image.resize({ w: width, h: height })
|
||||
|
||||
// Jimp always outputs with alpha; use PNG for alpha sources, JPEG otherwise
|
||||
const hasAlpha = image.hasAlpha()
|
||||
const mime = hasAlpha ? 'image/png' : 'image/jpeg'
|
||||
const resizedBuffer = hasAlpha
|
||||
? await image.getBuffer('image/png')
|
||||
: await image.getBuffer('image/jpeg', { quality: 75 })
|
||||
|
||||
const originalKB = Math.round(base64Data.length / 1024)
|
||||
const resizedB64 = resizedBuffer.toString('base64')
|
||||
const resizedKB = Math.round(resizedB64.length / 1024)
|
||||
logger.debug('Resized image for Copilot', {
|
||||
original: `${origWidth}x${origHeight} (${originalKB}KB)`,
|
||||
resized: `${width}x${height} (${resizedKB}KB)`,
|
||||
})
|
||||
|
||||
return `data:${mime};base64,${resizedB64}`
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import { startOAuthCallbackServer } from './callback-server'
|
||||
import { OAuthTokenManager } from './token-manager'
|
||||
import { OAuthTokenStore } from './token-store'
|
||||
|
||||
let tokenManager: OAuthTokenManager | null = null
|
||||
|
||||
export function initializeOAuth(
|
||||
db: Database,
|
||||
browserosId: string,
|
||||
): OAuthTokenManager {
|
||||
const store = new OAuthTokenStore(db)
|
||||
tokenManager = new OAuthTokenManager(store, browserosId)
|
||||
startOAuthCallbackServer(tokenManager)
|
||||
return tokenManager
|
||||
}
|
||||
|
||||
export function getOAuthTokenManager(): OAuthTokenManager | null {
|
||||
return tokenManager
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
|
||||
export interface OAuthProviderConfig {
|
||||
id: string
|
||||
name: string
|
||||
clientId: string
|
||||
authEndpoint: string
|
||||
tokenEndpoint: string
|
||||
scopes: string[]
|
||||
extraAuthParams?: Record<string, string>
|
||||
upstreamLLMProvider: string
|
||||
authFlow?: 'pkce' | 'device-code'
|
||||
/** Device code flow uses form-urlencoded instead of JSON */
|
||||
deviceCodeContentType?: 'json' | 'form'
|
||||
/** Device code flow requires PKCE code_challenge/code_verifier */
|
||||
deviceCodeRequiresPKCE?: boolean
|
||||
}
|
||||
|
||||
export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'chatgpt-pro': {
|
||||
id: 'chatgpt-pro',
|
||||
name: 'ChatGPT Plus/Pro',
|
||||
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
|
||||
authEndpoint: EXTERNAL_URLS.OPENAI_AUTH,
|
||||
tokenEndpoint: EXTERNAL_URLS.OPENAI_TOKEN,
|
||||
scopes: ['openid', 'profile', 'email', 'offline_access'],
|
||||
extraAuthParams: {
|
||||
id_token_add_organizations: 'true',
|
||||
codex_cli_simplified_flow: 'true',
|
||||
originator: 'browseros',
|
||||
},
|
||||
upstreamLLMProvider: 'openai',
|
||||
},
|
||||
'github-copilot': {
|
||||
id: 'github-copilot',
|
||||
name: 'GitHub Copilot',
|
||||
clientId: 'Ov23li8tweQw6odWQebz',
|
||||
authEndpoint: EXTERNAL_URLS.GITHUB_DEVICE_CODE,
|
||||
tokenEndpoint: EXTERNAL_URLS.GITHUB_OAUTH_TOKEN,
|
||||
scopes: ['read:user'],
|
||||
upstreamLLMProvider: 'github-copilot',
|
||||
authFlow: 'device-code',
|
||||
},
|
||||
'qwen-code': {
|
||||
id: 'qwen-code',
|
||||
name: 'Qwen Code',
|
||||
clientId: 'f0304373b74a44d2b584a3fb70ca9e56',
|
||||
authEndpoint: EXTERNAL_URLS.QWEN_DEVICE_CODE,
|
||||
tokenEndpoint: EXTERNAL_URLS.QWEN_OAUTH_TOKEN,
|
||||
scopes: ['openid', 'profile', 'email', 'model.completion'],
|
||||
upstreamLLMProvider: 'qwen-code',
|
||||
authFlow: 'device-code',
|
||||
deviceCodeContentType: 'form',
|
||||
deviceCodeRequiresPKCE: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function getOAuthProvider(
|
||||
providerId: string,
|
||||
): OAuthProviderConfig | undefined {
|
||||
return OAUTH_PROVIDERS[providerId]
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports'
|
||||
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
|
||||
import { logger } from '../../logger'
|
||||
import { getOAuthProvider, type OAuthProviderConfig } from './providers'
|
||||
import type { OAuthTokenStore, StoredOAuthTokens } from './token-store'
|
||||
|
||||
interface PendingOAuthFlow {
|
||||
provider: string
|
||||
codeVerifier: string
|
||||
state: string
|
||||
redirectBackUrl?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in: number
|
||||
id_token?: string
|
||||
}
|
||||
|
||||
export interface DeviceCodeResult {
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
interface DeviceCodeResponse {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
verification_uri_complete?: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
interface DeviceCodeTokenPollResponse {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
error?: string
|
||||
interval?: number
|
||||
}
|
||||
|
||||
export class OAuthTokenManager {
|
||||
private readonly pendingFlows = new Map<string, PendingOAuthFlow>()
|
||||
private readonly refreshLocks = new Map<
|
||||
string,
|
||||
Promise<StoredOAuthTokens | null>
|
||||
>()
|
||||
|
||||
constructor(
|
||||
private readonly store: OAuthTokenStore,
|
||||
private readonly browserosId: string,
|
||||
) {}
|
||||
|
||||
// --- PKCE flow (ChatGPT Plus/Pro) ---
|
||||
|
||||
async generateAuthorizationUrl(
|
||||
providerId: string,
|
||||
redirectBackUrl?: string,
|
||||
): Promise<string> {
|
||||
const provider = getOAuthProvider(providerId)
|
||||
if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`)
|
||||
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
||||
const state = generateRandomState()
|
||||
|
||||
this.pendingFlows.set(state, {
|
||||
provider: providerId,
|
||||
codeVerifier,
|
||||
state,
|
||||
redirectBackUrl,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
this.cleanExpiredFlows()
|
||||
|
||||
const redirectUri = buildRedirectUri()
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: provider.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
scope: provider.scopes.join(' '),
|
||||
state,
|
||||
...provider.extraAuthParams,
|
||||
})
|
||||
|
||||
return `${provider.authEndpoint}?${params.toString()}`
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<{ tokens: StoredOAuthTokens; redirectBackUrl?: string }> {
|
||||
const flow = this.pendingFlows.get(state)
|
||||
if (!flow) throw new Error('Invalid or expired OAuth state')
|
||||
if (Date.now() - flow.createdAt > TIMEOUTS.OAUTH_FLOW_TTL) {
|
||||
this.pendingFlows.delete(state)
|
||||
throw new Error('OAuth flow expired. Please try again.')
|
||||
}
|
||||
|
||||
const provider = getOAuthProvider(flow.provider)
|
||||
if (!provider) throw new Error(`Unknown OAuth provider: ${flow.provider}`)
|
||||
|
||||
const redirectUri = buildRedirectUri()
|
||||
const tokenResponse = await fetch(provider.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: provider.clientId,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: flow.codeVerifier,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.text()
|
||||
logger.error('OAuth token exchange failed', {
|
||||
status: tokenResponse.status,
|
||||
error,
|
||||
})
|
||||
throw new Error(`Token exchange failed: ${tokenResponse.status}`)
|
||||
}
|
||||
|
||||
const data = (await tokenResponse.json()) as OAuthTokenResponse
|
||||
if (!data.refresh_token) {
|
||||
logger.warn(
|
||||
'OAuth token response missing refresh_token — token refresh will not be available',
|
||||
{
|
||||
provider: flow.provider,
|
||||
},
|
||||
)
|
||||
}
|
||||
const { accountId, email } = parseAccessTokenClaims(data.access_token)
|
||||
|
||||
const tokens: StoredOAuthTokens = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? '',
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
email,
|
||||
accountId,
|
||||
}
|
||||
|
||||
this.store.upsertTokens(this.browserosId, flow.provider, tokens)
|
||||
this.pendingFlows.delete(state)
|
||||
|
||||
logger.info('OAuth authentication successful', {
|
||||
provider: flow.provider,
|
||||
email,
|
||||
})
|
||||
|
||||
return { tokens, redirectBackUrl: flow.redirectBackUrl }
|
||||
}
|
||||
|
||||
// --- Device Code flow (GitHub Copilot) ---
|
||||
|
||||
private readonly activeDeviceFlows = new Set<string>()
|
||||
|
||||
async startDeviceCodeFlow(providerId: string): Promise<DeviceCodeResult> {
|
||||
const provider = getOAuthProvider(providerId)
|
||||
if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`)
|
||||
|
||||
// Cancel any existing flow — user may be retrying
|
||||
this.activeDeviceFlows.delete(providerId)
|
||||
|
||||
// PKCE: generate verifier/challenge if provider requires it
|
||||
let codeVerifier: string | undefined
|
||||
const params: Record<string, string> = {
|
||||
client_id: provider.clientId,
|
||||
scope: provider.scopes.join(' '),
|
||||
}
|
||||
if (provider.deviceCodeRequiresPKCE) {
|
||||
codeVerifier = generateCodeVerifier()
|
||||
params.code_challenge = await generateCodeChallenge(codeVerifier)
|
||||
params.code_challenge_method = 'S256'
|
||||
}
|
||||
|
||||
// Build request body (form-urlencoded or JSON based on provider)
|
||||
const useForm = provider.deviceCodeContentType === 'form'
|
||||
const response = await fetch(provider.authEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': useForm
|
||||
? 'application/x-www-form-urlencoded'
|
||||
: 'application/json',
|
||||
},
|
||||
body: useForm
|
||||
? new URLSearchParams(params).toString()
|
||||
: JSON.stringify(params),
|
||||
})
|
||||
|
||||
// Detect WAF/captcha responses (HTML instead of JSON)
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error(
|
||||
'Authentication service temporarily unavailable. Please try again in a few minutes.',
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to request device code: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as DeviceCodeResponse
|
||||
|
||||
// Some providers return 200 with an error payload
|
||||
const dataObj = data as unknown as Record<string, unknown>
|
||||
if ('error' in dataObj) {
|
||||
throw new Error(`Device code error: ${dataObj.error}`)
|
||||
}
|
||||
if (!data.device_code || !data.user_code) {
|
||||
throw new Error('Invalid device code response')
|
||||
}
|
||||
|
||||
// Start background polling with error handling
|
||||
this.activeDeviceFlows.add(providerId)
|
||||
this.pollDeviceCode(
|
||||
providerId,
|
||||
provider,
|
||||
data.device_code,
|
||||
data.interval,
|
||||
data.expires_in,
|
||||
codeVerifier,
|
||||
).finally(() => this.activeDeviceFlows.delete(providerId))
|
||||
|
||||
return {
|
||||
userCode: data.user_code,
|
||||
verificationUri: data.verification_uri_complete ?? data.verification_uri,
|
||||
expiresIn: data.expires_in,
|
||||
}
|
||||
}
|
||||
|
||||
private async pollDeviceCode(
|
||||
providerId: string,
|
||||
provider: OAuthProviderConfig,
|
||||
deviceCode: string,
|
||||
initialInterval: number,
|
||||
expiresIn: number,
|
||||
codeVerifier?: string,
|
||||
): Promise<void> {
|
||||
let interval = initialInterval
|
||||
const deadline = Date.now() + expiresIn * 1000
|
||||
const useForm = provider.deviceCodeContentType === 'form'
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(interval * 1000 + TIMEOUTS.DEVICE_CODE_POLL_SAFETY_MARGIN)
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
client_id: provider.clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||
}
|
||||
if (codeVerifier) params.code_verifier = codeVerifier
|
||||
|
||||
const response = await fetch(provider.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': useForm
|
||||
? 'application/x-www-form-urlencoded'
|
||||
: 'application/json',
|
||||
},
|
||||
body: useForm
|
||||
? new URLSearchParams(params).toString()
|
||||
: JSON.stringify(params),
|
||||
})
|
||||
|
||||
// WAF returned HTML instead of JSON — retry later
|
||||
const ct = response.headers.get('content-type') ?? ''
|
||||
if (!ct.includes('application/json')) {
|
||||
logger.warn('WAF blocked poll request, retrying', {
|
||||
provider: providerId,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const data = (await response.json()) as DeviceCodeTokenPollResponse
|
||||
|
||||
// Token received — store and return
|
||||
if (data.access_token) {
|
||||
const tokens: StoredOAuthTokens = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? '',
|
||||
expiresAt: data.expires_in
|
||||
? Date.now() + data.expires_in * 1000
|
||||
: 0,
|
||||
email: undefined,
|
||||
accountId: undefined,
|
||||
}
|
||||
this.store.upsertTokens(this.browserosId, providerId, tokens)
|
||||
logger.info('Device code OAuth successful', { provider: providerId })
|
||||
return
|
||||
}
|
||||
|
||||
// Handle polling errors per RFC 8628
|
||||
if (data.error === 'authorization_pending') continue
|
||||
if (data.error === 'slow_down') {
|
||||
interval = (data.interval ?? interval) + 5
|
||||
continue
|
||||
}
|
||||
if (data.error === 'expired_token' || data.error === 'access_denied') {
|
||||
logger.warn('Device code flow ended', {
|
||||
provider: providerId,
|
||||
error: data.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.warn('Unexpected device code poll response', {
|
||||
provider: providerId,
|
||||
error: data.error,
|
||||
})
|
||||
return
|
||||
} catch (err) {
|
||||
// Transient network error — loop continues to retry
|
||||
logger.warn('Device code poll request failed, retrying', {
|
||||
provider: providerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('Device code flow timed out', { provider: providerId })
|
||||
}
|
||||
|
||||
// --- Token refresh ---
|
||||
|
||||
async refreshIfExpired(provider: string): Promise<StoredOAuthTokens | null> {
|
||||
const tokens = this.store.getTokens(this.browserosId, provider)
|
||||
if (!tokens) return null
|
||||
|
||||
// GitHub Copilot tokens never expire (expiresAt = 0)
|
||||
if (tokens.expiresAt === 0) return tokens
|
||||
|
||||
if (Date.now() < tokens.expiresAt - TIMEOUTS.OAUTH_TOKEN_EXPIRY_BUFFER) {
|
||||
return tokens
|
||||
}
|
||||
|
||||
const existing = this.refreshLocks.get(provider)
|
||||
if (existing) return existing
|
||||
|
||||
const refreshPromise = this.executeRefresh(provider, tokens)
|
||||
this.refreshLocks.set(provider, refreshPromise)
|
||||
|
||||
try {
|
||||
return await refreshPromise
|
||||
} finally {
|
||||
this.refreshLocks.delete(provider)
|
||||
}
|
||||
}
|
||||
|
||||
private async executeRefresh(
|
||||
provider: string,
|
||||
tokens: StoredOAuthTokens,
|
||||
): Promise<StoredOAuthTokens> {
|
||||
if (!tokens.refreshToken) {
|
||||
this.store.deleteTokens(this.browserosId, provider)
|
||||
throw new Error(
|
||||
`${provider} session expired (no refresh token). Please re-login.`,
|
||||
)
|
||||
}
|
||||
|
||||
const providerConfig = getOAuthProvider(provider)
|
||||
if (!providerConfig) {
|
||||
throw new Error(`Unknown OAuth provider: ${provider}`)
|
||||
}
|
||||
|
||||
logger.debug('Refreshing OAuth token', { provider })
|
||||
|
||||
const response = await fetch(providerConfig.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: providerConfig.clientId,
|
||||
refresh_token: tokens.refreshToken,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('OAuth token refresh failed', {
|
||||
provider,
|
||||
status: response.status,
|
||||
})
|
||||
this.store.deleteTokens(this.browserosId, provider)
|
||||
const providerName = providerConfig.name
|
||||
throw new Error(`${providerName} session expired. Please re-login.`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as OAuthTokenResponse
|
||||
const { accountId, email } = parseAccessTokenClaims(data.access_token)
|
||||
|
||||
const refreshed: StoredOAuthTokens = {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? tokens.refreshToken,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
email: email ?? tokens.email,
|
||||
accountId: accountId ?? tokens.accountId,
|
||||
}
|
||||
|
||||
this.store.upsertTokens(this.browserosId, provider, refreshed)
|
||||
return refreshed
|
||||
}
|
||||
|
||||
// --- Shared ---
|
||||
|
||||
// Store tokens provided by the extension (client-side auth flow)
|
||||
storeTokens(
|
||||
provider: string,
|
||||
params: { accessToken: string; refreshToken: string; expiresIn: number },
|
||||
): void {
|
||||
const tokens: StoredOAuthTokens = {
|
||||
accessToken: params.accessToken,
|
||||
refreshToken: params.refreshToken,
|
||||
expiresAt: params.expiresIn ? Date.now() + params.expiresIn * 1000 : 0,
|
||||
email: undefined,
|
||||
accountId: undefined,
|
||||
}
|
||||
this.store.upsertTokens(this.browserosId, provider, tokens)
|
||||
}
|
||||
|
||||
getTokens(provider: string): StoredOAuthTokens | null {
|
||||
return this.store.getTokens(this.browserosId, provider)
|
||||
}
|
||||
|
||||
getStatus(provider: string) {
|
||||
return this.store.getStatus(this.browserosId, provider)
|
||||
}
|
||||
|
||||
deleteTokens(provider: string): void {
|
||||
this.store.deleteTokens(this.browserosId, provider)
|
||||
}
|
||||
|
||||
private cleanExpiredFlows(): void {
|
||||
const now = Date.now()
|
||||
for (const [state, flow] of this.pendingFlows) {
|
||||
if (now - flow.createdAt > TIMEOUTS.OAUTH_FLOW_TTL) {
|
||||
this.pendingFlows.delete(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildRedirectUri(): string {
|
||||
return `http://localhost:${OAUTH_CALLBACK_PORT}/auth/callback`
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
||||
return base64UrlEncode(bytes)
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(verifier))
|
||||
return base64UrlEncode(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
function generateRandomState(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
||||
return base64UrlEncode(bytes)
|
||||
}
|
||||
|
||||
function base64UrlEncode(bytes: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...bytes))
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// Extracts claims without signature verification — safe because the token
|
||||
// comes directly from OpenAI's HTTPS token endpoint. Do not reuse for
|
||||
// caller-supplied or externally-sourced tokens.
|
||||
function parseAccessTokenClaims(accessToken: string): {
|
||||
accountId?: string
|
||||
email?: string
|
||||
} {
|
||||
try {
|
||||
const parts = accessToken.split('.')
|
||||
if (parts.length !== 3) return {}
|
||||
const payload = JSON.parse(
|
||||
atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')),
|
||||
)
|
||||
const authClaims = payload['https://api.openai.com/auth']
|
||||
const profileClaims = payload['https://api.openai.com/profile']
|
||||
return {
|
||||
accountId:
|
||||
authClaims?.chatgpt_account_id ??
|
||||
payload.chatgpt_account_id ??
|
||||
payload.account_id,
|
||||
email: profileClaims?.email ?? payload.email,
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user