mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
8 Commits
feat/inter
...
agent-test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11aca93cf5 | ||
|
|
bd619d1a29 | ||
|
|
f18ae0237c | ||
|
|
81744417ab | ||
|
|
9ec4445804 | ||
|
|
02bbb3fdd5 | ||
|
|
f11d16c674 | ||
|
|
64ddcc2a07 |
65
.github/workflows/cla.yml
vendored
65
.github/workflows/cla.yml
vendored
@@ -1,11 +1,11 @@
|
||||
name: CLA Assistant
|
||||
|
||||
name: 'CLA Assistant'
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
# Explicitly configure permissions
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
@@ -13,46 +13,47 @@ permissions:
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
cla:
|
||||
CLAAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
(github.event_name == 'pull_request_target') ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
|
||||
(github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'))
|
||||
steps:
|
||||
- name: CLA Assistant
|
||||
- name: 'CLA Assistant'
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
uses: contributor-assistant/github-action@v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURES_TOKEN }}
|
||||
with:
|
||||
path-to-signatures: 'cla-signatures.json'
|
||||
path-to-document: 'https://github.com/${{ github.repository }}/blob/main/CLA.md'
|
||||
# Path where signatures will be stored
|
||||
path-to-signatures: 'signatures/version1/cla.json'
|
||||
|
||||
# Path to your CLA document
|
||||
path-to-document: 'https://github.com/browseros-ai/BrowserOS/blob/main/CLA.md'
|
||||
|
||||
# Branch to store signatures (should not be protected)
|
||||
branch: 'main'
|
||||
remote-organization-name: 'browseros-ai'
|
||||
remote-repository-name: 'cla-signatures'
|
||||
allowlist: 'shadowfax92,felarof99,bot*,*[bot],dependabot,renovate,github-actions,snyk-bot,imgbot,greenkeeper,semantic-release-bot,allcontributors'
|
||||
lock-pullrequest-aftermerge: false
|
||||
|
||||
# Allowlist for users who don't need to sign (bots, core team members)
|
||||
allowlist: shadowfax92,felarof99,dependabot[bot],renovate[bot],github-actions[bot]
|
||||
|
||||
# Optional: Custom messages
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement](https://github.com/${{ github.repository }}/blob/main/CLA.md).
|
||||
**CLA Assistant Lite bot** Thank you for your submission! We require contributors to sign our [Contributor License Agreement](https://github.com/browseros-ai/BrowserOS/blob/main/CLA.md) before we can accept your contribution.
|
||||
|
||||
**To sign the CLA**, please add a comment to this PR with the following text:
|
||||
By signing the CLA, you confirm that:
|
||||
- You have read and agree to the AGPL-3.0 license terms
|
||||
- Your contribution is your original work
|
||||
- You grant us the rights to use your contribution under the AGPL-3.0 license
|
||||
|
||||
```
|
||||
I have read the CLA Document and I hereby sign the CLA
|
||||
```
|
||||
**To sign the CLA, please comment on this PR with:**
|
||||
`I have read the CLA Document and I hereby sign the CLA`
|
||||
|
||||
You only need to sign once. After signing, this check will pass automatically.
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary>Troubleshooting</summary>
|
||||
|
||||
- **Already signed but still failing?** Comment `recheck` to trigger a re-verification.
|
||||
- **Signed with a different email?** Make sure your commit email matches your GitHub account email, or add your commit email to your GitHub account.
|
||||
|
||||
</details>
|
||||
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
|
||||
|
||||
custom-allsigned-prcomment: |
|
||||
All contributors have signed the CLA. Thank you!
|
||||
**CLA Assistant Lite bot** ✅ All contributors have signed the CLA. Thank you for helping make BrowserOS better!
|
||||
|
||||
# Lock PR after merge to prevent signature tampering
|
||||
lock-pullrequest-aftermerge: true
|
||||
|
||||
# Custom commit messages
|
||||
create-file-commit-message: 'docs: Create CLA signatures file'
|
||||
signed-commit-message: 'docs: $contributorName signed the CLA in $owner/$repo#$pullRequestNo'
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,6 +26,3 @@ gclient.json
|
||||
**/resources/binaries/
|
||||
|
||||
packages/browseros/build/tools/
|
||||
|
||||
# AI SDK DevTools traces
|
||||
.devtools/
|
||||
|
||||
3840
.vscode/PythonImportHelper-v2-Completion.json
vendored
Normal file
3840
.vscode/PythonImportHelper-v2-Completion.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
57
lefthook.yml
57
lefthook.yml
@@ -1,57 +0,0 @@
|
||||
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
|
||||
41
packages/browseros-agent/.github/dependabot.yml
vendored
Normal file
41
packages/browseros-agent/.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: bun
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: 'sunday'
|
||||
time: '02:00'
|
||||
timezone: Europe/Berlin
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
dependencies:
|
||||
applies-to: security-updates
|
||||
dependency-type: production
|
||||
exclude-patterns:
|
||||
- 'puppeteer*'
|
||||
patterns:
|
||||
- '*'
|
||||
dev-dependencies:
|
||||
applies-to: security-updates
|
||||
dependency-type: development
|
||||
exclude-patterns:
|
||||
- 'puppeteer*'
|
||||
patterns:
|
||||
- '*'
|
||||
puppeteer:
|
||||
patterns:
|
||||
- 'puppeteer*'
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: 'sunday'
|
||||
time: '04:00'
|
||||
timezone: Europe/Berlin
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
all:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- '*'
|
||||
@@ -9,9 +9,6 @@ on:
|
||||
jobs:
|
||||
security-audit:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
58
packages/browseros-agent/.github/workflows/cla.yml
vendored
Normal file
58
packages/browseros-agent/.github/workflows/cla.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: CLA Assistant
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
cla:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
(github.event_name == 'pull_request_target') ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
|
||||
(github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'))
|
||||
steps:
|
||||
- name: CLA Assistant
|
||||
uses: contributor-assistant/github-action@v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_SIGNATURES_TOKEN }}
|
||||
with:
|
||||
path-to-signatures: 'cla-signatures.json'
|
||||
path-to-document: 'https://github.com/${{ github.repository }}/blob/main/CLA.md'
|
||||
branch: 'main'
|
||||
remote-organization-name: 'browseros-ai'
|
||||
remote-repository-name: 'cla-signatures'
|
||||
allowlist: 'bot*,*[bot],dependabot,renovate,github-actions,snyk-bot,imgbot,greenkeeper,semantic-release-bot,allcontributors'
|
||||
lock-pullrequest-aftermerge: false
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for your contribution! Before we can merge this PR, we need you to sign our [Contributor License Agreement](https://github.com/${{ github.repository }}/blob/main/CLA.md).
|
||||
|
||||
**To sign the CLA**, please add a comment to this PR with the following text:
|
||||
|
||||
```
|
||||
I have read the CLA Document and I hereby sign the CLA
|
||||
```
|
||||
|
||||
You only need to sign once. After signing, this check will pass automatically.
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary>Troubleshooting</summary>
|
||||
|
||||
- **Already signed but still failing?** Comment `recheck` to trigger a re-verification.
|
||||
- **Signed with a different email?** Make sure your commit email matches your GitHub account email, or add your commit email to your GitHub account.
|
||||
|
||||
</details>
|
||||
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
|
||||
custom-allsigned-prcomment: |
|
||||
All contributors have signed the CLA. Thank you!
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
contents: write # Can push branches and create commits
|
||||
pull-requests: write # Can create and update PRs
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -38,5 +38,11 @@ jobs:
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Allow all tools - branch protection rules at repo level prevent direct pushes to main/master
|
||||
# Omitting --allowedTools means all tools are available by default
|
||||
|
||||
@@ -4,16 +4,11 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/browseros-agent/**"
|
||||
|
||||
jobs:
|
||||
biome:
|
||||
name: runner / Biome
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -33,9 +28,6 @@ jobs:
|
||||
typecheck:
|
||||
name: runner / Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -50,9 +42,6 @@ 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
|
||||
|
||||
@@ -5,9 +5,9 @@ on:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
pull-requests: write # Read PR details and add labels
|
||||
issues: write # Labels are managed via issues API
|
||||
contents: read # Read repository content
|
||||
|
||||
jobs:
|
||||
validate-pr-title:
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent/packages/agent-sdk
|
||||
working-directory: packages/agent-sdk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
working-directory: packages/browseros-agent
|
||||
working-directory: .
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -7,21 +7,18 @@ jobs:
|
||||
name: Run Tests
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Bun
|
||||
- name: 🧰 Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
- name: 📦 Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Run all tests
|
||||
- name: 🧪 Run all tests
|
||||
run: bun test:all
|
||||
env:
|
||||
PUPPETEER_EXECUTABLE_PATH: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"vcs": {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChevronDown, LogIn, LogOut, User } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { i18n } from '#i18n'
|
||||
import ProductLogo from '@/assets/product_logo.svg'
|
||||
import { ThemeToggle } from '@/components/elements/theme-toggle'
|
||||
import {
|
||||
@@ -69,11 +68,7 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<img
|
||||
src={ProductLogo}
|
||||
alt={i18n.t('sidebar.branding.alt')}
|
||||
className="size-8"
|
||||
/>
|
||||
<img src={ProductLogo} alt="BrowserOS" className="size-8" />
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -110,9 +105,7 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
|
||||
: 'font-medium text-primary',
|
||||
)}
|
||||
>
|
||||
{isLoggedIn
|
||||
? i18n.t('sidebar.branding.personal')
|
||||
: i18n.t('sidebar.branding.signIn')}
|
||||
{isLoggedIn ? 'Personal' : 'Sign in'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -130,14 +123,14 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
|
||||
{displayName}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
{i18n.t('sidebar.branding.personal')}
|
||||
Personal
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate('/profile')}>
|
||||
<User className="mr-2 size-4" />
|
||||
{i18n.t('sidebar.branding.updateProfile')}
|
||||
Update Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@@ -145,13 +138,13 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
|
||||
variant="destructive"
|
||||
>
|
||||
<LogOut className="mr-2 size-4" />
|
||||
{i18n.t('sidebar.branding.signOut')}
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => navigate('/login')}>
|
||||
<LogIn className="mr-2 size-4" />
|
||||
{i18n.t('sidebar.branding.signIn')}
|
||||
Sign in
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
import { i18n } from '#i18n'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -25,44 +24,40 @@ interface SidebarNavigationProps {
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
nameKey: string
|
||||
name: string
|
||||
to: string
|
||||
icon: typeof Home
|
||||
feature?: Feature
|
||||
}
|
||||
|
||||
const primaryNavItems: NavItem[] = [
|
||||
{ nameKey: 'sidebar.nav.home', to: '/home', icon: Home },
|
||||
{ name: 'Home', to: '/home', icon: Home },
|
||||
{
|
||||
nameKey: 'sidebar.nav.connectApps',
|
||||
name: 'Connect Apps',
|
||||
to: '/connect-apps',
|
||||
icon: PlugZap,
|
||||
feature: Feature.MANAGED_MCP_SUPPORT,
|
||||
},
|
||||
{ name: 'Scheduled Tasks', to: '/scheduled', icon: CalendarClock },
|
||||
{
|
||||
nameKey: 'sidebar.nav.scheduledTasks',
|
||||
to: '/scheduled',
|
||||
icon: CalendarClock,
|
||||
},
|
||||
{
|
||||
nameKey: 'sidebar.nav.skills',
|
||||
name: 'Skills',
|
||||
to: '/home/skills',
|
||||
icon: Wand2,
|
||||
feature: Feature.SKILLS_SUPPORT,
|
||||
},
|
||||
{
|
||||
nameKey: 'sidebar.nav.memory',
|
||||
name: 'Memory',
|
||||
to: '/home/memory',
|
||||
icon: Brain,
|
||||
feature: Feature.MEMORY_SUPPORT,
|
||||
},
|
||||
{
|
||||
nameKey: 'sidebar.nav.soul',
|
||||
name: 'Soul',
|
||||
to: '/home/soul',
|
||||
icon: Sparkles,
|
||||
feature: Feature.SOUL_SUPPORT,
|
||||
},
|
||||
{ nameKey: 'sidebar.nav.settings', to: '/settings/ai', icon: Settings },
|
||||
{ name: 'Settings', to: '/settings/ai', icon: Settings },
|
||||
]
|
||||
|
||||
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
@@ -102,7 +97,7 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
expanded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
{i18n.t(item.nameKey as never)}
|
||||
{item.name}
|
||||
</span>
|
||||
</NavLink>
|
||||
)
|
||||
@@ -111,9 +106,7 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
return (
|
||||
<Tooltip key={item.to}>
|
||||
<TooltipTrigger asChild>{navItem}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{i18n.t(item.nameKey as never)}
|
||||
</TooltipContent>
|
||||
<TooltipContent side="right">{item.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Info, Keyboard } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -51,7 +50,7 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
|
||||
expanded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
{i18n.t('sidebar.footer.about')}
|
||||
About BrowserOS
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
@@ -69,7 +68,7 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
|
||||
expanded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
{i18n.t('sidebar.footer.shortcuts')}
|
||||
Shortcuts
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
@@ -82,9 +81,7 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{shortcutsButton}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{i18n.t('sidebar.footer.shortcuts')}
|
||||
</TooltipContent>
|
||||
<TooltipContent side="right">Shortcuts</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -93,9 +90,7 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{aboutLink}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{i18n.t('sidebar.footer.about')}
|
||||
</TooltipContent>
|
||||
<TooltipContent side="right">About BrowserOS</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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, AlertDescription, AlertTitle }
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
|
||||
@@ -104,10 +104,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
||||
@@ -251,10 +251,10 @@ function CarouselNext({
|
||||
}
|
||||
|
||||
export {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
|
||||
@@ -39,4 +39,4 @@ function CollapsibleContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleContent, CollapsibleTrigger }
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
||||
@@ -198,11 +198,11 @@ function CommandShortcut({
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
|
||||
@@ -283,18 +283,18 @@ function DropdownMenuSubContent({
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
||||
@@ -179,12 +179,12 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
useFormField,
|
||||
FormField,
|
||||
}
|
||||
|
||||
@@ -50,4 +50,4 @@ function HoverCardContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger }
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
|
||||
@@ -184,7 +184,7 @@ export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
|
||||
@@ -55,4 +55,4 @@ function PopoverAnchor({
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
|
||||
@@ -49,4 +49,4 @@ function ResizableHandle({
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
|
||||
@@ -129,11 +129,11 @@ function SheetDescription({
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetDescription,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ 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, TabsContent, TabsList, TabsTrigger, tabsListVariants }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
|
||||
@@ -68,4 +68,4 @@ function TooltipContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
||||
@@ -2,7 +2,6 @@ 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'
|
||||
@@ -80,7 +79,6 @@ 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 />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -13,27 +13,14 @@ 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,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
|
||||
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
|
||||
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
|
||||
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
|
||||
import {
|
||||
getProviderTemplate,
|
||||
type ProviderTemplate,
|
||||
} from '@/lib/llm-providers/providerTemplates'
|
||||
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 { useOAuthStatus } from '@/lib/llm-providers/useOAuthStatus'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
|
||||
import {
|
||||
DeleteRemoteLlmProviderDocument,
|
||||
@@ -114,117 +101,12 @@ export const AISettingsPage: FC = () => {
|
||||
null,
|
||||
)
|
||||
|
||||
// OAuth status for ChatGPT Plus/Pro
|
||||
const {
|
||||
status: chatgptProStatus,
|
||||
startPolling: startChatGPTProPolling,
|
||||
disconnect: disconnectChatGPTPro,
|
||||
} = useOAuthStatus('chatgpt-pro')
|
||||
|
||||
// OAuth status for GitHub Copilot
|
||||
const {
|
||||
status: copilotStatus,
|
||||
startPolling: startCopilotPolling,
|
||||
disconnect: disconnectCopilot,
|
||||
} = useOAuthStatus('github-copilot')
|
||||
|
||||
// Track whether user explicitly started an OAuth flow this session
|
||||
const oauthFlowStartedRef = useRef(false)
|
||||
const copilotOAuthStartedRef = useRef(false)
|
||||
|
||||
// Auto-create provider only when user actively completed OAuth,
|
||||
// not on passive page load when server has old tokens
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
|
||||
useEffect(() => {
|
||||
if (!chatgptProStatus?.authenticated) return
|
||||
if (!oauthFlowStartedRef.current) return
|
||||
|
||||
const exists = providers.some((p) => p.type === 'chatgpt-pro')
|
||||
if (exists) return
|
||||
|
||||
const now = Date.now()
|
||||
try {
|
||||
const template = getProviderTemplate('chatgpt-pro')
|
||||
saveProvider({
|
||||
id: `chatgpt-pro-${now}`,
|
||||
type: 'chatgpt-pro',
|
||||
name: `ChatGPT Plus/Pro${chatgptProStatus.email ? ` (${chatgptProStatus.email})` : ''}`,
|
||||
modelId: template?.defaultModelId ?? 'gpt-5.3-codex',
|
||||
supportsImages: template?.supportsImages ?? true,
|
||||
contextWindow: template?.contextWindow ?? 400000,
|
||||
temperature: 0.2,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
track(CHATGPT_PRO_OAUTH_COMPLETED_EVENT, {
|
||||
email: chatgptProStatus.email,
|
||||
})
|
||||
toast.success('ChatGPT Plus/Pro Connected', {
|
||||
description: chatgptProStatus.email
|
||||
? `Authenticated as ${chatgptProStatus.email}`
|
||||
: 'Successfully authenticated with ChatGPT Plus/Pro',
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error('Failed to create ChatGPT Plus/Pro provider', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
} finally {
|
||||
oauthFlowStartedRef.current = false
|
||||
}
|
||||
}, [chatgptProStatus?.authenticated])
|
||||
|
||||
// Auto-create GitHub Copilot provider on successful OAuth
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
|
||||
useEffect(() => {
|
||||
if (!copilotStatus?.authenticated) return
|
||||
if (!copilotOAuthStartedRef.current) return
|
||||
|
||||
const exists = providers.some((p) => p.type === 'github-copilot')
|
||||
if (exists) return
|
||||
|
||||
const now = Date.now()
|
||||
try {
|
||||
const template = getProviderTemplate('github-copilot')
|
||||
saveProvider({
|
||||
id: `github-copilot-${now}`,
|
||||
type: 'github-copilot',
|
||||
name: 'GitHub Copilot',
|
||||
modelId: template?.defaultModelId ?? 'gpt-4o',
|
||||
supportsImages: template?.supportsImages ?? true,
|
||||
contextWindow: template?.contextWindow ?? 128000,
|
||||
temperature: 0.2,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
track(GITHUB_COPILOT_OAUTH_COMPLETED_EVENT)
|
||||
toast.success('GitHub Copilot Connected', {
|
||||
description: 'Successfully authenticated with GitHub Copilot',
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error('Failed to create GitHub Copilot provider', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
} finally {
|
||||
copilotOAuthStartedRef.current = false
|
||||
}
|
||||
}, [copilotStatus?.authenticated])
|
||||
|
||||
const handleAddProvider = () => {
|
||||
setTemplateValues(undefined)
|
||||
setIsNewDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleUseTemplate = (template: ProviderTemplate) => {
|
||||
// OAuth providers: trigger OAuth flow instead of opening form dialog
|
||||
if (template.id === 'chatgpt-pro') {
|
||||
handleStartChatGPTProOAuth()
|
||||
return
|
||||
}
|
||||
if (template.id === 'github-copilot') {
|
||||
handleStartGitHubCopilotOAuth()
|
||||
return
|
||||
}
|
||||
|
||||
setTemplateValues({
|
||||
type: template.id,
|
||||
name: template.name,
|
||||
@@ -237,68 +119,6 @@ export const AISettingsPage: FC = () => {
|
||||
setIsNewDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleStartChatGPTProOAuth = () => {
|
||||
if (!agentServerUrl) {
|
||||
toast.error('Server not available', {
|
||||
description: 'Cannot start OAuth flow without server connection.',
|
||||
})
|
||||
return
|
||||
}
|
||||
oauthFlowStartedRef.current = true
|
||||
|
||||
const extensionSettingsUrl = chrome.runtime.getURL('app.html#/ai-settings')
|
||||
const startUrl = `${agentServerUrl}/oauth/chatgpt-pro/start?redirect=${encodeURIComponent(extensionSettingsUrl)}`
|
||||
window.open(startUrl, '_blank')
|
||||
|
||||
// Start polling for OAuth completion
|
||||
startChatGPTProPolling()
|
||||
track(CHATGPT_PRO_OAUTH_STARTED_EVENT)
|
||||
toast.info('Authenticating with ChatGPT Plus/Pro', {
|
||||
description: 'Complete the login in the opened tab.',
|
||||
})
|
||||
}
|
||||
|
||||
const handleStartGitHubCopilotOAuth = async () => {
|
||||
if (!agentServerUrl) {
|
||||
toast.error('Server not available', {
|
||||
description: 'Cannot start OAuth flow without server connection.',
|
||||
})
|
||||
return
|
||||
}
|
||||
copilotOAuthStartedRef.current = true
|
||||
|
||||
try {
|
||||
// Device Code flow: get user code from server, then open GitHub
|
||||
const res = await fetch(`${agentServerUrl}/oauth/github-copilot/start`)
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`)
|
||||
|
||||
const data = (await res.json()) as {
|
||||
userCode?: string
|
||||
verificationUri?: string
|
||||
}
|
||||
|
||||
if (!data.userCode || !data.verificationUri) {
|
||||
throw new Error('Invalid response from server')
|
||||
}
|
||||
|
||||
// Open GitHub device verification page
|
||||
window.open(data.verificationUri, '_blank')
|
||||
|
||||
// Start polling for completion
|
||||
startCopilotPolling()
|
||||
track(GITHUB_COPILOT_OAUTH_STARTED_EVENT)
|
||||
toast.info(`Enter code: ${data.userCode}`, {
|
||||
description: 'Paste this code on the GitHub page that just opened.',
|
||||
duration: 60_000,
|
||||
})
|
||||
} catch (err) {
|
||||
copilotOAuthStartedRef.current = false
|
||||
toast.error('Failed to start GitHub Copilot authentication', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditProvider = (provider: LlmProviderConfig) => {
|
||||
setEditingProvider(provider)
|
||||
setIsEditDialogOpen(true)
|
||||
@@ -310,15 +130,6 @@ export const AISettingsPage: FC = () => {
|
||||
|
||||
const confirmDeleteProvider = async () => {
|
||||
if (providerToDelete) {
|
||||
// Clear OAuth tokens on server for OAuth-based providers
|
||||
if (providerToDelete.type === 'chatgpt-pro') {
|
||||
await disconnectChatGPTPro()
|
||||
track(CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT)
|
||||
}
|
||||
if (providerToDelete.type === 'github-copilot') {
|
||||
await disconnectCopilot()
|
||||
track(GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT)
|
||||
}
|
||||
await deleteProvider(providerToDelete.id)
|
||||
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
|
||||
setProviderToDelete(null)
|
||||
|
||||
@@ -61,8 +61,6 @@ const providerTypeEnum = z.enum([
|
||||
'lmstudio',
|
||||
'bedrock',
|
||||
'browseros',
|
||||
'chatgpt-pro',
|
||||
'github-copilot',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -86,9 +84,6 @@ 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
|
||||
@@ -132,10 +127,6 @@ export const providerFormSchema = z
|
||||
})
|
||||
}
|
||||
}
|
||||
// OAuth providers: no credentials needed (server-managed)
|
||||
else if (data.type === 'chatgpt-pro' || data.type === 'github-copilot') {
|
||||
// No validation needed — OAuth tokens are on the server
|
||||
}
|
||||
// Other providers: require baseUrl
|
||||
else if (!data.baseUrl) {
|
||||
ctx.addIssue({
|
||||
@@ -191,10 +182,6 @@ 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 === 'moonshot')
|
||||
return kimiLaunch || initialValues?.type === 'moonshot'
|
||||
if (opt.value === 'openai-compatible') {
|
||||
@@ -222,8 +209,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
secretAccessKey: initialValues?.secretAccessKey || '',
|
||||
region: initialValues?.region || '',
|
||||
sessionToken: initialValues?.sessionToken || '',
|
||||
reasoningEffort: initialValues?.reasoningEffort || 'high',
|
||||
reasoningSummary: initialValues?.reasoningSummary || 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -316,8 +301,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
secretAccessKey: initialValues.secretAccessKey || '',
|
||||
region: initialValues.region || '',
|
||||
sessionToken: initialValues.sessionToken || '',
|
||||
reasoningEffort: initialValues.reasoningEffort || 'high',
|
||||
reasoningSummary: initialValues.reasoningSummary || 'auto',
|
||||
})
|
||||
setIsCustomModel(false)
|
||||
}
|
||||
@@ -343,8 +326,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
secretAccessKey: '',
|
||||
region: '',
|
||||
sessionToken: '',
|
||||
reasoningEffort: 'high',
|
||||
reasoningSummary: 'auto',
|
||||
})
|
||||
setIsCustomModel(false)
|
||||
}
|
||||
@@ -382,10 +363,6 @@ 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')
|
||||
return true
|
||||
|
||||
if (watchedType === 'azure') {
|
||||
return !!(watchedResourceName || watchedBaseUrl) && !!watchedApiKey
|
||||
}
|
||||
@@ -467,84 +444,6 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
}
|
||||
|
||||
const renderProviderSpecificFields = () => {
|
||||
// GitHub Copilot: OAuth credentials only
|
||||
if (watchedType === 'github-copilot') {
|
||||
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 GitHub 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,10 +103,8 @@ export const ProviderCard: FC<ProviderCardProps> = ({
|
||||
for better performance.
|
||||
</>
|
||||
)
|
||||
) : provider.baseUrl ? (
|
||||
`${provider.modelId} • ${provider.baseUrl}`
|
||||
) : (
|
||||
provider.modelId
|
||||
`${provider.modelId} • ${provider.baseUrl}`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -26,10 +26,6 @@ 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 === 'moonshot') return kimiLaunch
|
||||
if (template.id === 'openai-compatible') {
|
||||
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
|
||||
|
||||
@@ -23,8 +23,6 @@ export interface ModelsData {
|
||||
bedrock: ModelInfo[]
|
||||
browseros: ModelInfo[]
|
||||
moonshot: ModelInfo[]
|
||||
'chatgpt-pro': ModelInfo[]
|
||||
'github-copilot': ModelInfo[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,42 +90,6 @@ 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 },
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -156,7 +156,6 @@ export const ConnectMCP: FC = () => {
|
||||
})
|
||||
if (response.success) {
|
||||
removeServer(id)
|
||||
mutateUserIntegrations()
|
||||
} else {
|
||||
failedToRemoveMcp(name, 'Success not returned from server')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import useSWR from 'swr'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
interface UserMCPIntegrationsList {
|
||||
@@ -9,11 +9,7 @@ interface UserMCPIntegrationsList {
|
||||
count: number
|
||||
}
|
||||
|
||||
export const INTEGRATIONS_QUERY_KEY = 'klavis-user-integrations'
|
||||
|
||||
const getUserMCPIntegrations = async (
|
||||
hostUrl: string,
|
||||
): Promise<UserMCPIntegrationsList> => {
|
||||
const getUserMCPIntegrations = async ([hostUrl]: [hostUrl: string]) => {
|
||||
const response = await fetch(`${hostUrl}/klavis/user-integrations`)
|
||||
const data = (await response.json()) as UserMCPIntegrationsList
|
||||
return data
|
||||
@@ -22,19 +18,12 @@ const getUserMCPIntegrations = async (
|
||||
export const useGetUserMCPIntegrations = () => {
|
||||
const { baseUrl: agentServerUrl } = useAgentServerUrl()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [INTEGRATIONS_QUERY_KEY, agentServerUrl],
|
||||
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled
|
||||
queryFn: () => getUserMCPIntegrations(agentServerUrl!),
|
||||
enabled: !!agentServerUrl,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
|
||||
return {
|
||||
data: query.data,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isSuccess: query.isSuccess,
|
||||
mutate: query.refetch,
|
||||
}
|
||||
return useSWR(
|
||||
agentServerUrl ? [agentServerUrl, 'klavis/user-integrations'] : null,
|
||||
getUserMCPIntegrations,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { MessageResponse } from '@/components/ai-elements/message'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
|
||||
import type { Message } from './useSurveyChat'
|
||||
import { useVoiceInput } from './useVoiceInput'
|
||||
import { VoiceInputButton } from './VoiceInputButton'
|
||||
|
||||
interface Props {
|
||||
@@ -81,7 +81,6 @@ export const Chat: FC<Props> = ({
|
||||
}, [messagesLength])
|
||||
|
||||
// Insert transcript into input when transcription completes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on transcript/transcribing change
|
||||
useEffect(() => {
|
||||
if (voice.transcript && !voice.isTranscribing) {
|
||||
setInput((prev) => {
|
||||
@@ -90,7 +89,7 @@ export const Chat: FC<Props> = ({
|
||||
})
|
||||
voice.clearTranscript()
|
||||
}
|
||||
}, [voice.transcript, voice.isTranscribing])
|
||||
}, [voice])
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const GATEWAY_URL = 'https://llm.browseros.com'
|
||||
const WAVEFORM_BAND_COUNT = 5
|
||||
|
||||
export interface VoiceInputState {
|
||||
isRecording: boolean
|
||||
isTranscribing: boolean
|
||||
audioLevels: number[]
|
||||
error: string | null
|
||||
onStartRecording: () => void
|
||||
onStopRecording: () => void
|
||||
}
|
||||
|
||||
export interface UseVoiceInputReturn {
|
||||
interface UseVoiceInputReturn {
|
||||
isRecording: boolean
|
||||
isTranscribing: boolean
|
||||
transcript: string
|
||||
audioLevel: number
|
||||
audioLevels: number[]
|
||||
error: string | null
|
||||
startRecording: () => Promise<boolean>
|
||||
startRecording: () => Promise<void>
|
||||
stopRecording: () => Promise<void>
|
||||
clearTranscript: () => void
|
||||
}
|
||||
|
||||
const EMPTY_LEVELS = Array(WAVEFORM_BAND_COUNT).fill(0)
|
||||
|
||||
interface TranscribeResponse {
|
||||
text: string
|
||||
}
|
||||
|
||||
async function transcribeAudio(audioBlob: Blob): Promise<string> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', audioBlob, 'recording.webm')
|
||||
@@ -38,19 +21,16 @@ async function transcribeAudio(audioBlob: Blob): Promise<string> {
|
||||
const response = await fetch(`${GATEWAY_URL}/api/transcribe`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody: { error?: string } = await response
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ error: 'Transcription failed' }))
|
||||
throw new Error(
|
||||
errorBody.error || `Transcription failed: ${response.status}`,
|
||||
)
|
||||
throw new Error(error.error || `Transcription failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const result: TranscribeResponse = await response.json()
|
||||
const result = await response.json()
|
||||
return result.text || ''
|
||||
}
|
||||
|
||||
@@ -59,7 +39,6 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
const [isTranscribing, setIsTranscribing] = useState(false)
|
||||
const [transcript, setTranscript] = useState('')
|
||||
const [audioLevel, setAudioLevel] = useState(0)
|
||||
const [audioLevels, setAudioLevels] = useState<number[]>(EMPTY_LEVELS)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
@@ -69,7 +48,7 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
const analyserRef = useRef<AnalyserNode | null>(null)
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
|
||||
const stopAudioLevelMonitoring = () => {
|
||||
const stopAudioLevelMonitoring = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
@@ -80,10 +59,8 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
audioContextRef.current = null
|
||||
analyserRef.current = null
|
||||
setAudioLevel(0)
|
||||
setAudioLevels(EMPTY_LEVELS)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
@@ -94,9 +71,9 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
}
|
||||
stopAudioLevelMonitoring()
|
||||
}
|
||||
}, [])
|
||||
}, [stopAudioLevelMonitoring])
|
||||
|
||||
const startAudioLevelMonitoring = (stream: MediaStream) => {
|
||||
const startAudioLevelMonitoring = useCallback((stream: MediaStream) => {
|
||||
const audioContext = new AudioContext()
|
||||
const analyser = audioContext.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
@@ -110,36 +87,20 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
const updateLevel = () => {
|
||||
if (!analyserRef.current) return
|
||||
|
||||
const dataArray = new Uint8Array(analyserRef.current.fftSize)
|
||||
analyserRef.current.getByteTimeDomainData(dataArray)
|
||||
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount)
|
||||
analyserRef.current.getByteFrequencyData(dataArray)
|
||||
|
||||
const binCount = dataArray.length
|
||||
const levels: number[] = []
|
||||
let totalPeak = 0
|
||||
|
||||
for (let band = 0; band < WAVEFORM_BAND_COUNT; band++) {
|
||||
const start = Math.floor((band / WAVEFORM_BAND_COUNT) * binCount)
|
||||
const end = Math.floor(((band + 1) / WAVEFORM_BAND_COUNT) * binCount)
|
||||
let peak = 0
|
||||
for (let j = start; j < end; j++) {
|
||||
const amplitude = Math.abs(dataArray[j] - 128)
|
||||
if (amplitude > peak) peak = amplitude
|
||||
}
|
||||
const normalized = Math.round(Math.min(100, (peak / 50) * 100))
|
||||
levels.push(normalized)
|
||||
totalPeak += normalized
|
||||
}
|
||||
|
||||
setAudioLevels(levels)
|
||||
setAudioLevel(Math.round(totalPeak / WAVEFORM_BAND_COUNT))
|
||||
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length
|
||||
const normalized = Math.min(100, (average / 128) * 100)
|
||||
setAudioLevel(Math.round(normalized))
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(updateLevel)
|
||||
}
|
||||
|
||||
updateLevel()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startRecording = async (): Promise<boolean> => {
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
setTranscript('')
|
||||
@@ -172,14 +133,7 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
|
||||
mediaRecorder.start(250)
|
||||
setIsRecording(true)
|
||||
return true
|
||||
} catch (err) {
|
||||
streamRef.current?.getTracks().forEach((track) => {
|
||||
track.stop()
|
||||
})
|
||||
streamRef.current = null
|
||||
stopAudioLevelMonitoring()
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setError('Microphone permission denied')
|
||||
@@ -191,11 +145,10 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
} else {
|
||||
setError('Failed to start recording')
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}, [startAudioLevelMonitoring])
|
||||
|
||||
const stopRecording = async () => {
|
||||
const stopRecording = useCallback(async () => {
|
||||
const mediaRecorder = mediaRecorderRef.current
|
||||
|
||||
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
|
||||
@@ -235,19 +188,18 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
} finally {
|
||||
setIsTranscribing(false)
|
||||
}
|
||||
}
|
||||
}, [stopAudioLevelMonitoring])
|
||||
|
||||
const clearTranscript = () => {
|
||||
const clearTranscript = useCallback(() => {
|
||||
setTranscript('')
|
||||
setError(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isTranscribing,
|
||||
transcript,
|
||||
audioLevel,
|
||||
audioLevels,
|
||||
error,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
@@ -17,9 +17,12 @@ export const SettingsSidebarLayout: FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
track(SETTINGS_PAGE_VIEWED_EVENT, { page: location.pathname })
|
||||
setMobileOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
}, [])
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<RpcClientProvider>
|
||||
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
@@ -23,6 +25,10 @@ export const SidebarLayout: FC = () => {
|
||||
setShortcutsDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
track(SETTINGS_PAGE_VIEWED_EVENT, { page: location.pathname })
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
}, [])
|
||||
@@ -97,17 +103,11 @@ export const SidebarLayout: FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Main content - full width, centered */}
|
||||
{location.pathname === '/home/chat' ? (
|
||||
<main className="relative h-dvh overflow-hidden">
|
||||
<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 />
|
||||
</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>
|
||||
</main>
|
||||
</div>
|
||||
<ShortcutsDialog
|
||||
open={shortcutsDialogOpen}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ChevronDown, Loader2, Sparkles, Undo2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect } 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'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
@@ -35,15 +31,6 @@ 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 { track } from '@/lib/metrics/track'
|
||||
import { refinePrompt } from '@/lib/schedules/refine-prompt'
|
||||
import type { ScheduledJob } from './types'
|
||||
|
||||
const formSchema = z
|
||||
@@ -56,7 +43,6 @@ const formSchema = z
|
||||
scheduleType: z.enum(['daily', 'hourly', 'minutes']),
|
||||
scheduleTime: z.string().optional(),
|
||||
scheduleInterval: z.number().int().min(1).max(60).optional(),
|
||||
providerId: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
@@ -95,8 +81,6 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
onSave,
|
||||
}) => {
|
||||
const isEditing = !!initialValues
|
||||
const [providers, setProviders] = useState<LlmProviderConfig[]>([])
|
||||
const [defaultProviderId, setDefaultProviderId] = useState<string>('')
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -106,36 +90,14 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
scheduleType: 'daily',
|
||||
scheduleTime: '09:00',
|
||||
scheduleInterval: 1,
|
||||
providerId: undefined,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const scheduleType = form.watch('scheduleType')
|
||||
const selectedProviderId = form.watch('providerId')
|
||||
const queryValue = form.watch('query')
|
||||
const [isRefining, setIsRefining] = useState(false)
|
||||
const originalPromptRef = useRef<string | null>(null)
|
||||
const refineRequestIdRef = useRef(0)
|
||||
const isProgrammaticChange = useRef(false)
|
||||
|
||||
// Load providers from storage
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
Promise.all([
|
||||
providersStorage.getValue(),
|
||||
defaultProviderIdStorage.getValue(),
|
||||
]).then(([providerList, defId]) => {
|
||||
setProviders(providerList ?? [])
|
||||
setDefaultProviderId(defId ?? '')
|
||||
})
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
refineRequestIdRef.current++
|
||||
originalPromptRef.current = null
|
||||
setIsRefining(false)
|
||||
if (initialValues) {
|
||||
form.reset({
|
||||
name: initialValues.name,
|
||||
@@ -143,7 +105,6 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
scheduleType: initialValues.scheduleType,
|
||||
scheduleTime: initialValues.scheduleTime || '09:00',
|
||||
scheduleInterval: initialValues.scheduleInterval || 1,
|
||||
providerId: initialValues.providerId,
|
||||
enabled: initialValues.enabled,
|
||||
})
|
||||
} else {
|
||||
@@ -153,87 +114,12 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
scheduleType: 'daily',
|
||||
scheduleTime: '09:00',
|
||||
scheduleInterval: 1,
|
||||
providerId: undefined,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [open, initialValues, form])
|
||||
|
||||
// Resolve the currently selected provider for the selector display
|
||||
const resolvedProvider: Provider | null = (() => {
|
||||
const id = selectedProviderId ?? defaultProviderId
|
||||
const found = providers.find((p) => p.id === id)
|
||||
if (found) return { id: found.id, name: found.name, type: found.type }
|
||||
if (providers[0])
|
||||
return {
|
||||
id: providers[0].id,
|
||||
name: providers[0].name,
|
||||
type: providers[0].type,
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
const providerOptions: Provider[] = providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
}))
|
||||
|
||||
// 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()
|
||||
if (!currentQuery) return
|
||||
|
||||
const requestId = ++refineRequestIdRef.current
|
||||
setIsRefining(true)
|
||||
originalPromptRef.current = currentQuery
|
||||
|
||||
try {
|
||||
const refined = await refinePrompt({
|
||||
prompt: currentQuery,
|
||||
name: currentName || 'Untitled Task',
|
||||
providerId: form.getValues('providerId'),
|
||||
})
|
||||
if (requestId !== refineRequestIdRef.current) return
|
||||
setQueryWithUndo(refined)
|
||||
track(SCHEDULED_TASK_PROMPT_REFINED_EVENT)
|
||||
} catch {
|
||||
if (requestId !== refineRequestIdRef.current) return
|
||||
toast.error('Failed to rewrite prompt. Please try again.')
|
||||
originalPromptRef.current = null
|
||||
} finally {
|
||||
if (requestId === refineRequestIdRef.current) {
|
||||
setIsRefining(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUndoRefine = () => {
|
||||
if (originalPromptRef.current !== null) {
|
||||
setQueryWithUndo(originalPromptRef.current)
|
||||
originalPromptRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
onSave({
|
||||
name: values.name.trim(),
|
||||
@@ -243,11 +129,9 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
values.scheduleType === 'daily' ? values.scheduleTime : undefined,
|
||||
scheduleInterval:
|
||||
values.scheduleType !== 'daily' ? values.scheduleInterval : undefined,
|
||||
providerId: values.providerId,
|
||||
enabled: values.enabled,
|
||||
})
|
||||
form.reset()
|
||||
originalPromptRef.current = null
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
@@ -285,96 +169,22 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
name="query"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Prompt</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 px-2 py-1 text-muted-foreground text-xs"
|
||||
disabled={!queryValue?.trim() || isRefining}
|
||||
onClick={handleRefinePrompt}
|
||||
>
|
||||
{isRefining ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3" />
|
||||
)}
|
||||
{isRefining ? 'Rewriting...' : 'Rewrite with AI'}
|
||||
</Button>
|
||||
</div>
|
||||
<FormLabel>Prompt</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What should the agent do? e.g., Check my email and summarize important messages"
|
||||
className="min-h-[100px] resize-none"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e)
|
||||
if (
|
||||
!isProgrammaticChange.current &&
|
||||
originalPromptRef.current !== null
|
||||
) {
|
||||
originalPromptRef.current = null
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{!isRefining && originalPromptRef.current !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-muted-foreground text-xs hover:text-foreground"
|
||||
onClick={handleUndoRefine}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
Undo rewrite
|
||||
</button>
|
||||
) : (
|
||||
<FormDescription>
|
||||
The instruction that will be sent to the agent
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormDescription>
|
||||
The instruction that will be sent to the agent
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{providers.length > 0 && resolvedProvider && (
|
||||
<FormItem>
|
||||
<FormLabel>AI Provider</FormLabel>
|
||||
<ChatProviderSelector
|
||||
providers={providerOptions}
|
||||
selectedProvider={resolvedProvider}
|
||||
onSelectProvider={(provider) =>
|
||||
form.setValue('providerId', provider.id)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
{resolvedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={16} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
type={resolvedProvider.type as ProviderType}
|
||||
size={16}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{resolvedProvider.name}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</ChatProviderSelector>
|
||||
<FormDescription>
|
||||
The AI provider used to run this task
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -20,9 +20,6 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import { providersStorage } from '@/lib/llm-providers/storage'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
import { useScheduledJobRuns } from '@/lib/schedules/scheduleStorage'
|
||||
import type { ScheduledJob, ScheduledJobRun } from './types'
|
||||
|
||||
@@ -83,25 +80,9 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
|
||||
onRetryRun,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [providerInfo, setProviderInfo] = useState<{
|
||||
name: string
|
||||
type: ProviderType
|
||||
} | null>(null)
|
||||
|
||||
const { jobRuns } = useScheduledJobRuns()
|
||||
|
||||
// Load provider info for display
|
||||
useEffect(() => {
|
||||
if (!job.providerId) {
|
||||
setProviderInfo(null)
|
||||
return
|
||||
}
|
||||
providersStorage.getValue().then((providers) => {
|
||||
const match = providers?.find((p) => p.id === job.providerId)
|
||||
setProviderInfo(match ? { name: match.name, type: match.type } : null)
|
||||
})
|
||||
}, [job.providerId])
|
||||
|
||||
const runs = useMemo(
|
||||
() =>
|
||||
jobRuns
|
||||
@@ -136,19 +117,6 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<span>{formatSchedule(job)}</span>
|
||||
{providerInfo && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{providerInfo.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={12} />
|
||||
) : (
|
||||
<ProviderIcon type={providerInfo.type} size={12} />
|
||||
)}
|
||||
{providerInfo.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{job.lastRunAt && (
|
||||
<>
|
||||
<span>•</span>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AlertCircle, Eye, Pencil, Plus, Trash2, Wand2 } from 'lucide-react'
|
||||
import { AlertCircle, 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,
|
||||
@@ -109,19 +108,23 @@ export const SkillsPage: FC = () => {
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && skills.length > 0 ? (
|
||||
<SkillSections
|
||||
skills={skills}
|
||||
onEdit={handleEdit}
|
||||
onDelete={(skill) => setSkillToDelete(skill)}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
<SkillDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
editingSkill={editingSkill}
|
||||
readOnly={editingSkill?.builtIn}
|
||||
onSave={async (data) => {
|
||||
try {
|
||||
if (editingSkill) {
|
||||
@@ -248,50 +251,6 @@ 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
|
||||
@@ -301,14 +260,7 @@ 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">
|
||||
<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>
|
||||
<h2 className="font-semibold text-sm leading-5">{skill.name}</h2>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
@@ -329,29 +281,18 @@ const SkillCard: FC<{
|
||||
onClick={onEdit}
|
||||
className="-ml-2 h-7 px-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
>
|
||||
{skill.builtIn ? (
|
||||
<>
|
||||
<Eye className="size-3.5" />
|
||||
View
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</>
|
||||
)}
|
||||
<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" />
|
||||
</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>
|
||||
@@ -361,13 +302,12 @@ 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, readOnly, onSave }) => {
|
||||
}> = ({ open, onOpenChange, editingSkill, onSave }) => {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
@@ -414,18 +354,12 @@ 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>
|
||||
{readOnly
|
||||
? 'View Skill'
|
||||
: editingSkill
|
||||
? 'Edit Skill'
|
||||
: 'Create Skill'}
|
||||
{editingSkill ? 'Edit Skill' : 'Create Skill'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{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.'}
|
||||
{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>
|
||||
|
||||
@@ -439,7 +373,6 @@ 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.
|
||||
@@ -455,22 +388,19 @@ 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>
|
||||
|
||||
{!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 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>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col px-6 py-5">
|
||||
@@ -481,52 +411,36 @@ const SkillDialog: FC<{
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{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"
|
||||
/>
|
||||
)}
|
||||
<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">
|
||||
{readOnly
|
||||
? 'This skill is managed by BrowserOS and updated automatically.'
|
||||
: 'Saved locally and available to your agent immediately.'}
|
||||
Saved locally and available to your agent immediately.
|
||||
</p>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
<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,7 +7,6 @@ export type SkillMeta = {
|
||||
description: string
|
||||
location: string
|
||||
enabled: boolean
|
||||
builtIn: boolean
|
||||
}
|
||||
|
||||
export type SkillDetail = SkillMeta & {
|
||||
|
||||
@@ -18,7 +18,6 @@ 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'
|
||||
|
||||
@@ -67,12 +66,7 @@ export default defineBackground(() => {
|
||||
}
|
||||
})
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message?.type === 'get-tab-id') {
|
||||
sendResponse({ tabId: sender.tab?.id })
|
||||
return true
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
if (message?.type === 'AUTH_SUCCESS' && sender.tab?.id) {
|
||||
const tabId = sender.tab.id
|
||||
authRedirectPathStorage
|
||||
@@ -99,17 +93,6 @@ 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 {
|
||||
|
||||
@@ -117,7 +117,6 @@ export const scheduledJobRuns = async () => {
|
||||
const response = await getChatServerResponse({
|
||||
message: job.query,
|
||||
signal: abortController.signal,
|
||||
providerId: job.providerId,
|
||||
})
|
||||
|
||||
await updateJobRun(jobRun.id, {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Upload, X } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
@@ -49,9 +48,7 @@ export const ImportDataHint = () => {
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="size-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
{i18n.t('newtab.importData.title')}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-base">Import your data</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -63,7 +60,7 @@ export const ImportDataHint = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{i18n.t('newtab.importData.description')}
|
||||
Bring bookmarks, history, and passwords from Chrome.
|
||||
</CardDescription>
|
||||
<label
|
||||
htmlFor="import-dont-ask-again"
|
||||
@@ -76,11 +73,11 @@ export const ImportDataHint = () => {
|
||||
setDontAskAgain(checked === true)
|
||||
}
|
||||
/>
|
||||
{i18n.t('newtab.importData.dontShowAgain')}
|
||||
Don't show this again
|
||||
</label>
|
||||
<Button className="w-full" onClick={handleImport}>
|
||||
<Upload className="size-4" />
|
||||
{i18n.t('newtab.importData.openSettings')}
|
||||
Open Import Settings
|
||||
</Button>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
@@ -11,9 +11,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { i18n } from '#i18n'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import { AppSelector } from '@/components/elements/AppSelector'
|
||||
import {
|
||||
GlowingBorder,
|
||||
@@ -39,6 +36,7 @@ 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,
|
||||
@@ -47,8 +45,6 @@ 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'
|
||||
@@ -62,6 +58,7 @@ 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'
|
||||
@@ -81,13 +78,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: '',
|
||||
@@ -95,12 +92,13 @@ 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(
|
||||
@@ -130,9 +128,7 @@ export const NewTab = () => {
|
||||
query: inputValue,
|
||||
selectedTabs,
|
||||
})
|
||||
const searchPlaceholder = i18n.t('newtab.search.placeholder', [
|
||||
providerConfig.name,
|
||||
])
|
||||
const searchPlaceholder = `Ask BrowserOS or search ${providerConfig.name}...`
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
@@ -279,28 +275,17 @@ export const NewTab = () => {
|
||||
|
||||
const startInlineChat = (
|
||||
message: string,
|
||||
chatMode: 'chat' | 'agent',
|
||||
aiTab?: { name: string; description: string },
|
||||
mode: 'chat' | 'agent',
|
||||
action?: ReturnType<
|
||||
typeof createBrowserOSAction | typeof createAITabAction
|
||||
>,
|
||||
) => {
|
||||
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)
|
||||
track(NEWTAB_CHAT_STARTED_EVENT, { mode, tabs_count: selectedTabs.length })
|
||||
setMode(mode)
|
||||
setChatActive(true)
|
||||
sendMessage({ text: message, action })
|
||||
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) => {
|
||||
@@ -321,18 +306,15 @@ 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', {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
})
|
||||
startInlineChat(searchQuery, 'agent', action)
|
||||
} else {
|
||||
const action = createAITabAction({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
openSidePanelWithSearch('open', {
|
||||
query: searchQuery,
|
||||
mode: 'agent',
|
||||
@@ -348,14 +330,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)
|
||||
startInlineChat(item.message, item.mode, action)
|
||||
} else {
|
||||
const action = createBrowserOSAction({
|
||||
mode: item.mode,
|
||||
message: item.message,
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
openSidePanelWithSearch('open', {
|
||||
query: item.message,
|
||||
mode: item.mode,
|
||||
@@ -369,6 +351,12 @@ export const NewTab = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackToSearch = () => {
|
||||
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
|
||||
resetConversation()
|
||||
setChatActive(false)
|
||||
}
|
||||
|
||||
const isSuggestionsVisible =
|
||||
!mentionState.isOpen &&
|
||||
((isOpen && inputValue.length) ||
|
||||
@@ -380,6 +368,10 @@ export const NewTab = () => {
|
||||
track(NEWTAB_OPENED_EVENT)
|
||||
}, [])
|
||||
|
||||
if (chatActive) {
|
||||
return <NewTabChat onBackToSearch={handleBackToSearch} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-[max(25vh,16px)]">
|
||||
{/* Main content */}
|
||||
@@ -498,7 +490,7 @@ export const NewTab = () => {
|
||||
{selectedTab.title}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{i18n.t('newtab.selectedTab')}
|
||||
Tab
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -532,34 +524,6 @@ 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
|
||||
@@ -572,10 +536,7 @@ export const NewTab = () => {
|
||||
)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>
|
||||
{selectedFolder?.name ||
|
||||
i18n.t('newtab.addWorkspace')}
|
||||
</span>
|
||||
<span>{selectedFolder?.name || 'Add workspace'}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</WorkspaceSelector>
|
||||
@@ -598,7 +559,7 @@ export const NewTab = () => {
|
||||
)}
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
<span>{i18n.t('common.tabs')}</span>
|
||||
<span>Tabs</span>
|
||||
</Button>
|
||||
</TabPickerPopover>
|
||||
</div>
|
||||
@@ -608,7 +569,7 @@ export const NewTab = () => {
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{connectedManagedServers.length === 0 && (
|
||||
<span className="flex items-center gap-1 font-semibold text-[var(--accent-orange)] text-sm">
|
||||
{i18n.t('newtab.appsNew')}
|
||||
New!
|
||||
</span>
|
||||
)}
|
||||
{connectedManagedServers.length === 0 ? (
|
||||
@@ -629,13 +590,14 @@ export const NewTab = () => {
|
||||
)}
|
||||
>
|
||||
<PlugZap className="h-4 w-4" />
|
||||
<span>{i18n.t('common.apps')}</span>
|
||||
<span>Apps</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</AppSelector>
|
||||
<TooltipContent side="left" className="max-w-56">
|
||||
{i18n.t('newtab.appsTooltip')}
|
||||
Apps directly connected will have more accurate and
|
||||
faster responses for your queries!
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -672,7 +634,7 @@ export const NewTab = () => {
|
||||
+{connectedManagedServers.length - 4}
|
||||
</span>
|
||||
)}
|
||||
<span>{i18n.t('common.apps')}</span>
|
||||
<span>Apps</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</AppSelector>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { motion } from 'motion/react'
|
||||
import type { FC } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import ProductLogoSvg from '@/assets/product_logo.svg'
|
||||
|
||||
export const NewTabBranding: FC = () => {
|
||||
@@ -16,11 +15,7 @@ export const NewTabBranding: FC = () => {
|
||||
}}
|
||||
className="flex h-20 w-20 items-center justify-center rounded-xl bg-transparent"
|
||||
>
|
||||
<img
|
||||
src={ProductLogoSvg}
|
||||
alt={i18n.t('newtab.branding.alt')}
|
||||
className="h-20 w-20"
|
||||
/>
|
||||
<img src={ProductLogoSvg} alt="BrowserOS" className="h-20 w-20" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,41 +1,35 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
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'
|
||||
|
||||
export const NewTabChat: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const hasSentInitialRef = useRef(false)
|
||||
interface NewTabChatProps {
|
||||
onBackToSearch: () => void
|
||||
}
|
||||
|
||||
export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
const {
|
||||
mode,
|
||||
setMode,
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
agentUrlError,
|
||||
chatError,
|
||||
getActionForMessage,
|
||||
@@ -48,81 +42,72 @@ export const NewTabChat: FC = () => {
|
||||
selectedProvider,
|
||||
handleSelectProvider,
|
||||
resetConversation,
|
||||
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,
|
||||
},
|
||||
})
|
||||
} = useChatSessionContext()
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// 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(() => {
|
||||
if (hasSentInitialRef.current) return
|
||||
const query = searchParams.get('q')
|
||||
const chatMode = searchParams.get('mode')
|
||||
const tabIdsParam = searchParams.get('tabs')
|
||||
if (!query) return
|
||||
|
||||
hasSentInitialRef.current = true
|
||||
if (chatMode === 'chat' || chatMode === 'agent') {
|
||||
setMode(chatMode)
|
||||
}
|
||||
setSearchParams({}, { replace: true })
|
||||
|
||||
const actionType = searchParams.get('actionType')
|
||||
const tabName = searchParams.get('tabName')
|
||||
const tabDescription = searchParams.get('tabDescription')
|
||||
|
||||
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 })
|
||||
}
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
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 })
|
||||
}
|
||||
setInput('')
|
||||
setAttachedTabs([])
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
executeMessage()
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
track(NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT, { mode })
|
||||
executeMessage(suggestion)
|
||||
}
|
||||
|
||||
const handleNewConversation = () => {
|
||||
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
|
||||
resetConversation()
|
||||
@@ -131,19 +116,17 @@ export const NewTabChat: FC = () => {
|
||||
if (!selectedProvider) return null
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="flex h-[calc(100vh-2rem)] flex-col">
|
||||
<NewTabChatHeader
|
||||
selectedProvider={selectedProvider}
|
||||
providers={providers}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
onNewConversation={handleNewConversation}
|
||||
onBackToSearch={onBackToSearch}
|
||||
hasMessages={messages.length > 0}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col space-y-4 overflow-y-auto px-4 pt-4">
|
||||
{isRestoringConversation ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
@@ -173,7 +156,7 @@ export const NewTabChat: FC = () => {
|
||||
{chatError && <ChatError error={chatError} />}
|
||||
</main>
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl flex-shrink-0 px-4 pb-2">
|
||||
<div className="mx-auto w-full max-w-3xl px-4">
|
||||
<ChatFooter
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
@@ -185,7 +168,6 @@ export const NewTabChat: FC = () => {
|
||||
attachedTabs={attachedTabs}
|
||||
onToggleTab={toggleTabSelection}
|
||||
onRemoveTab={removeTab}
|
||||
voice={voiceState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChevronRight, Lightbulb, X } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { type FC, useState } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import { NEWTAB_TIP_DISMISSED_EVENT } from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { dismissTip, shouldShowTip, TIPS } from './tips'
|
||||
@@ -40,7 +39,7 @@ export const NewTabTip: FC = () => {
|
||||
<Lightbulb className="h-3.5 w-3.5 flex-shrink-0 text-[var(--accent-orange)]" />
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
<span className="font-semibold text-[var(--accent-orange)]">
|
||||
{i18n.t('newtab.tip.label')}
|
||||
Tip:
|
||||
</span>{' '}
|
||||
{tip.text}
|
||||
</p>
|
||||
@@ -48,7 +47,7 @@ export const NewTabTip: FC = () => {
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="flex-shrink-0 rounded-sm p-0.5 text-muted-foreground/50 opacity-0 transition-all hover:text-muted-foreground group-hover:opacity-100"
|
||||
title={i18n.t('newtab.tip.nextTip')}
|
||||
title="Next tip"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -56,7 +55,7 @@ export const NewTabTip: FC = () => {
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 rounded-sm p-0.5 text-muted-foreground/50 opacity-0 transition-all hover:text-muted-foreground group-hover:opacity-100"
|
||||
title={i18n.t('newtab.tip.dismiss')}
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { i18n } from '#i18n'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -26,10 +25,10 @@ export const ShortcutsDialog = ({
|
||||
<DialogContent className="styled-scrollbar max-h-[80vh] max-w-xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-semibold text-2xl">
|
||||
{i18n.t('newtab.shortcuts.title')}
|
||||
Keyboard Shortcuts
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{i18n.t('newtab.shortcuts.description')}
|
||||
Use these shortcuts to navigate BrowserOS faster
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -60,7 +59,7 @@ export const ShortcutsDialog = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-8 border-border/50 border-t pt-4 text-center text-muted-foreground text-xs">
|
||||
{i18n.t('newtab.shortcuts.moreComingSoon')}
|
||||
More shortcuts coming soon
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Cloud, X } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { i18n } from '#i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
@@ -48,9 +47,7 @@ export const SignInHint = () => {
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="size-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">
|
||||
{i18n.t('newtab.signIn.title')}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-base">Sync your data</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -62,7 +59,7 @@ export const SignInHint = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{i18n.t('newtab.signIn.description')}
|
||||
Sign in to sync conversation history to the cloud.
|
||||
</CardDescription>
|
||||
<label
|
||||
htmlFor="sync-dont-ask-again"
|
||||
@@ -75,10 +72,10 @@ export const SignInHint = () => {
|
||||
setDontAskAgain(checked === true)
|
||||
}
|
||||
/>
|
||||
{i18n.t('newtab.signIn.dontAskAgain')}
|
||||
Don't ask again
|
||||
</label>
|
||||
<Button className="w-full" onClick={() => navigate('/login')}>
|
||||
{i18n.t('newtab.signIn.signInButton')}
|
||||
Sign in
|
||||
</Button>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
@@ -3,19 +3,14 @@ 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">
|
||||
{!HIDE_FOCUS_GRID_PATHS.has(location.pathname) && <NewTabFocusGrid />}
|
||||
{location.pathname !== '/home/soul' &&
|
||||
location.pathname !== '/home/memory' &&
|
||||
location.pathname !== '/home/skills' && <NewTabFocusGrid />}
|
||||
<Outlet />
|
||||
</ChatSessionProvider>
|
||||
)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -8,14 +8,9 @@ import {
|
||||
SIDEPANEL_SUGGESTION_CLICKED_EVENT,
|
||||
SIDEPANEL_TAB_REMOVED_EVENT,
|
||||
SIDEPANEL_TAB_TOGGLED_EVENT,
|
||||
SIDEPANEL_VOICE_ERROR_EVENT,
|
||||
SIDEPANEL_VOICE_RECORDING_STARTED_EVENT,
|
||||
SIDEPANEL_VOICE_RECORDING_STOPPED_EVENT,
|
||||
SIDEPANEL_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { useJtbdPopup } from '@/lib/jtbd-popup/useJtbdPopup'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
|
||||
import { useChatSessionContext } from '../layout/ChatSessionContext'
|
||||
import { ChatEmptyState } from './ChatEmptyState'
|
||||
import { ChatError } from './ChatError'
|
||||
@@ -53,8 +48,6 @@ export const Chat = () => {
|
||||
onDismiss: onDismissJtbdPopup,
|
||||
} = useJtbdPopup()
|
||||
|
||||
const voice = useVoiceInput()
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [mounted, setMounted] = useState(false)
|
||||
@@ -90,26 +83,6 @@ export const Chat = () => {
|
||||
previousChatStatus.current = status
|
||||
}, [status])
|
||||
|
||||
// Insert transcript into input when transcription completes
|
||||
// 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(SIDEPANEL_VOICE_TRANSCRIPTION_COMPLETED_EVENT)
|
||||
voice.clearTranscript()
|
||||
}
|
||||
}, [voice.transcript, voice.isTranscribing])
|
||||
|
||||
// Track voice errors
|
||||
useEffect(() => {
|
||||
if (voice.error) {
|
||||
track(SIDEPANEL_VOICE_ERROR_EVENT, { error: voice.error })
|
||||
}
|
||||
}, [voice.error])
|
||||
|
||||
const handleModeChange = (newMode: ChatMode) => {
|
||||
track(SIDEPANEL_MODE_CHANGED_EVENT, { from: mode, to: newMode })
|
||||
setMode(newMode)
|
||||
@@ -174,27 +147,6 @@ export const Chat = () => {
|
||||
executeMessage(suggestion)
|
||||
}
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
const started = await voice.startRecording()
|
||||
if (started) {
|
||||
track(SIDEPANEL_VOICE_RECORDING_STARTED_EVENT)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRecording = async () => {
|
||||
await voice.stopRecording()
|
||||
track(SIDEPANEL_VOICE_RECORDING_STOPPED_EVENT)
|
||||
}
|
||||
|
||||
const voiceState = {
|
||||
isRecording: voice.isRecording,
|
||||
isTranscribing: voice.isTranscribing,
|
||||
audioLevels: voice.audioLevels,
|
||||
error: voice.error,
|
||||
onStartRecording: handleStartRecording,
|
||||
onStopRecording: handleStopRecording,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mt-4 flex h-full flex-1 flex-col space-y-4 overflow-y-auto">
|
||||
@@ -238,7 +190,6 @@ export const Chat = () => {
|
||||
attachedTabs={attachedTabs}
|
||||
onToggleTab={toggleTabSelection}
|
||||
onRemoveTab={removeTab}
|
||||
voice={voiceState}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Globe, X } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
|
||||
interface ChatAttachedTabsProps {
|
||||
tabs: chrome.tabs.Tab[]
|
||||
@@ -35,7 +34,7 @@ export const ChatAttachedTabs: FC<ChatAttachedTabsProps> = ({
|
||||
type="button"
|
||||
onClick={() => onRemoveTab(tab.id)}
|
||||
className="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-background"
|
||||
title={i18n.t('attachedTabs.removeTab')}
|
||||
title="Remove tab"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AGENT_SUGGESTIONS, CHAT_SUGGESTIONS, type ChatMode } from './chatTypes'
|
||||
|
||||
@@ -29,14 +28,12 @@ export const ChatEmptyState: FC<ChatEmptyStateProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-1 font-semibold text-lg">
|
||||
{mode === 'chat'
|
||||
? i18n.t('chat.empty.chatTitle')
|
||||
: i18n.t('chat.empty.agentTitle')}
|
||||
{mode === 'chat' ? 'Chat with this page' : 'Agent at your service'}
|
||||
</h2>
|
||||
<p className="max-w-[200px] text-muted-foreground text-xs">
|
||||
{mode === 'chat'
|
||||
? i18n.t('chat.empty.chatSubtitle')
|
||||
: i18n.t('chat.empty.agentSubtitle')}
|
||||
? 'Ask questions about the current page or any topic'
|
||||
: 'Let AI automate tasks and browse for you'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
// import { useMemo } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -39,7 +38,7 @@ function parseErrorMessage(message: string): {
|
||||
message.includes('127.0.0.1')
|
||||
) {
|
||||
return {
|
||||
text: i18n.t('chat.error.connectionMessage'),
|
||||
text: 'Unable to connect to BrowserOS agent. Follow below instructions.',
|
||||
url: 'https://docs.browseros.com/troubleshooting/connection-issues',
|
||||
isConnectionError: true,
|
||||
}
|
||||
@@ -48,7 +47,7 @@ function parseErrorMessage(message: string): {
|
||||
// Detect BrowserOS rate limit (unique pattern, no provider uses this)
|
||||
if (message.includes('BrowserOS LLM daily limit reached')) {
|
||||
return {
|
||||
text: i18n.t('chat.error.rateLimitMessage'),
|
||||
text: 'Add your own API key for unlimited usage.',
|
||||
url: 'https://dub.sh/browseros-usage-limit',
|
||||
isRateLimit: true,
|
||||
}
|
||||
@@ -84,9 +83,9 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
// --- End commented out survey code ---
|
||||
|
||||
const getTitle = () => {
|
||||
if (isRateLimit) return i18n.t('chat.error.dailyLimitTitle')
|
||||
if (isConnectionError) return i18n.t('chat.error.connectionFailedTitle')
|
||||
return i18n.t('chat.error.genericTitle')
|
||||
if (isRateLimit) return 'Daily limit reached'
|
||||
if (isConnectionError) return 'Connection failed'
|
||||
return 'Something went wrong'
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -103,7 +102,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
{i18n.t('chat.error.troubleshootingLink')}
|
||||
View troubleshooting guide
|
||||
</a>
|
||||
)}
|
||||
{/* --- Commented out for Kimi partnership launch (restore after) ---
|
||||
@@ -140,7 +139,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
className="underline hover:text-foreground"
|
||||
onClick={() => track(KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT)}
|
||||
>
|
||||
{i18n.t('chat.error.kimiApiKeyLink')}
|
||||
Learn how to get a Kimi API key
|
||||
</a>
|
||||
{' or '}
|
||||
<a
|
||||
@@ -150,7 +149,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
className="underline hover:text-foreground"
|
||||
onClick={() => track(KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT)}
|
||||
>
|
||||
{i18n.t('chat.error.getApiKey')}
|
||||
get your API key
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -163,7 +162,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
className="mt-1 gap-2"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{i18n.t('chat.error.tryAgain')}
|
||||
Try again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChevronDown, Folder, Layers, PlugZap } from 'lucide-react'
|
||||
import type { FC, FormEvent } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import { AppSelector } from '@/components/elements/AppSelector'
|
||||
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
|
||||
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
|
||||
@@ -9,17 +8,12 @@ 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 { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
|
||||
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 {
|
||||
@@ -33,7 +27,6 @@ interface ChatFooterProps {
|
||||
attachedTabs: chrome.tabs.Tab[]
|
||||
onToggleTab: (tab: chrome.tabs.Tab) => void
|
||||
onRemoveTab: (tabId?: number) => void
|
||||
voice?: VoiceInputState
|
||||
}
|
||||
|
||||
export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
@@ -47,40 +40,13 @@ export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
attachedTabs,
|
||||
onToggleTab,
|
||||
onRemoveTab,
|
||||
voice,
|
||||
}) => {
|
||||
const { selectedFolder } = useWorkspace()
|
||||
const { supports } = useCapabilities()
|
||||
const { servers: mcpServers } = useMcpServers()
|
||||
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
|
||||
useSyncRemoteIntegrations()
|
||||
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(() => {
|
||||
@@ -114,19 +80,6 @@ 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">
|
||||
@@ -143,7 +96,7 @@ export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
aria-expanded={isTabMentionOpen}
|
||||
aria-haspopup="dialog"
|
||||
className="flex cursor-pointer items-center gap-1 rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
|
||||
title={i18n.t('footer.attachTabs')}
|
||||
title="Attach tabs (@)"
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
{attachedTabs.length > 0 && (
|
||||
@@ -167,7 +120,7 @@ export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
title={
|
||||
selectedFolder
|
||||
? selectedFolder.name
|
||||
: i18n.t('footer.selectWorkspace')
|
||||
: 'Select workspace folder'
|
||||
}
|
||||
>
|
||||
<div className="relative">
|
||||
@@ -186,7 +139,7 @@ export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
|
||||
title={i18n.t('footer.connectApps')}
|
||||
title="Connect apps"
|
||||
>
|
||||
{connectedManagedServers.length > 0 ? (
|
||||
<>
|
||||
@@ -219,10 +172,6 @@ export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{voice?.error && (
|
||||
<div className="mt-1 text-destructive text-xs">{voice.error}</div>
|
||||
)}
|
||||
|
||||
<ChatInput
|
||||
input={input}
|
||||
status={status}
|
||||
@@ -233,7 +182,6 @@ export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
selectedTabs={attachedTabs}
|
||||
onToggleTab={onToggleTab}
|
||||
onTabMentionOpenChange={setIsTabMentionOpen}
|
||||
voice={voice}
|
||||
ref={chatInputRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Github, History, Plus, SettingsIcon } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router'
|
||||
import { i18n } from '#i18n'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { ThemeToggle } from '@/components/elements/theme-toggle'
|
||||
@@ -15,7 +14,6 @@ interface ChatHeaderProps {
|
||||
onSelectProvider: (provider: Provider) => void
|
||||
onNewConversation: () => void
|
||||
hasMessages: boolean
|
||||
hideHistory?: boolean
|
||||
}
|
||||
|
||||
export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
@@ -24,7 +22,6 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
onSelectProvider,
|
||||
onNewConversation,
|
||||
hasMessages,
|
||||
hideHistory,
|
||||
}) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
@@ -47,7 +44,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
<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={i18n.t('chat.header.changeProvider')}
|
||||
title="Change AI Provider"
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={18} />
|
||||
@@ -70,38 +67,37 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
type="button"
|
||||
onClick={onNewConversation}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title={i18n.t('chat.header.newConversation')}
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!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={i18n.t('chat.header.newConversation')}
|
||||
>
|
||||
<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={i18n.t('chat.header.chatHistory')}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Link>
|
||||
))}
|
||||
{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}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title={i18n.t('chat.header.starOnGithub')}
|
||||
title="Star on Github"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
</a>
|
||||
@@ -111,7 +107,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title={i18n.t('chat.header.settings')}
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader2, Mic, Send, Square, SquareStop } from 'lucide-react'
|
||||
import { Send, SquareStop } from 'lucide-react'
|
||||
import type { FormEvent, KeyboardEvent } from 'react'
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VoiceInputState } from '@/lib/voice/useVoiceInput'
|
||||
import type { ChatMode } from './chatTypes'
|
||||
|
||||
interface MentionState {
|
||||
@@ -30,7 +28,6 @@ interface ChatInputProps {
|
||||
selectedTabs: chrome.tabs.Tab[]
|
||||
onToggleTab: (tab: chrome.tabs.Tab) => void
|
||||
onTabMentionOpenChange?: (isOpen: boolean) => void
|
||||
voice?: VoiceInputState
|
||||
}
|
||||
|
||||
export interface ChatInputHandle {
|
||||
@@ -52,7 +49,6 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
selectedTabs,
|
||||
onToggleTab,
|
||||
onTabMentionOpenChange,
|
||||
voice,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -263,78 +259,6 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [mentionState.isOpen, closeMention])
|
||||
|
||||
const renderVoiceButton = () => {
|
||||
if (!voice) return null
|
||||
|
||||
if (voice.isRecording) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={voice.onStopRecording}
|
||||
className="cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">
|
||||
{i18n.t('chat.input.stopRecording')}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (voice.isTranscribing) {
|
||||
return (
|
||||
<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">{i18n.t('chat.input.transcribing')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={voice.onStartRecording}
|
||||
disabled={isBusy}
|
||||
className="cursor-pointer rounded-full p-2 text-muted-foreground transition-all duration-200 hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{i18n.t('chat.input.voiceInput')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSendButton = () => {
|
||||
if (isBusy) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900"
|
||||
>
|
||||
<SquareStop className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{i18n.t('chat.input.stop')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
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" />
|
||||
<span className="sr-only">{i18n.t('chat.input.send')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
@@ -349,43 +273,38 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
onClose={closeMention}
|
||||
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]">
|
||||
{voice.audioLevels.map((level, i) => (
|
||||
<div
|
||||
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`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
'field-sizing-content max-h-60 min-h-[42px] flex-1 resize-none overflow-hidden rounded-2xl border border-border/50 bg-muted/50 px-4 py-2.5 pr-11 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-border focus:border-[var(--accent-orange)]',
|
||||
)}
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
mode === 'chat' ? 'Ask about this page...' : 'What should I do?'
|
||||
}
|
||||
rows={1}
|
||||
/>
|
||||
{isBusy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="absolute right-1.5 bottom-1.5 cursor-pointer rounded-full bg-red-600 p-2 text-white shadow-sm transition-all duration-200 hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SquareStop className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Stop</span>
|
||||
</button>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
'field-sizing-content max-h-60 min-h-[42px] flex-1 resize-none overflow-hidden rounded-2xl border border-border/50 bg-muted/50 px-4 py-2.5 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-border focus:border-[var(--accent-orange)]',
|
||||
voice ? 'pr-[4.5rem]' : 'pr-11',
|
||||
)}
|
||||
value={input}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
voice?.isTranscribing
|
||||
? i18n.t('chat.input.placeholderTranscribing')
|
||||
: mode === 'chat'
|
||||
? i18n.t('chat.input.placeholderChat')
|
||||
: i18n.t('chat.input.placeholderAgent')
|
||||
}
|
||||
disabled={voice?.isTranscribing}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="absolute right-1.5 bottom-1.5 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" />
|
||||
<span className="sr-only">Send</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="absolute right-1.5 bottom-1.5 flex items-center gap-1">
|
||||
{renderVoiceButton()}
|
||||
{renderSendButton()}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CheckIcon, CopyIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { type FC, useState } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import { MessageAction, MessageActions } from '@/components/ai-elements/message'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -64,8 +63,8 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
navigator.clipboard.writeText(messageText)
|
||||
track(SIDEPANEL_MESSAGE_COPIED_EVENT)
|
||||
}}
|
||||
label={i18n.t('chat.actions.copy')}
|
||||
tooltip={i18n.t('chat.actions.copyToClipboard')}
|
||||
label="Copy"
|
||||
tooltip="Copy to clipboard"
|
||||
>
|
||||
<CopyIcon className="size-3" />
|
||||
</MessageAction>
|
||||
@@ -80,7 +79,7 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
className="flex items-center gap-1 text-muted-foreground text-xs"
|
||||
>
|
||||
<CheckIcon className="size-3" />
|
||||
<span>{i18n.t('chat.actions.feedbackSubmitted')}</span>
|
||||
<span>Feedback submitted</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
@@ -92,9 +91,9 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<MessageAction
|
||||
label={i18n.t('chat.actions.like')}
|
||||
label="Like"
|
||||
onClick={handleLike}
|
||||
tooltip={i18n.t('chat.actions.likeTooltip')}
|
||||
tooltip="Like this response"
|
||||
>
|
||||
<ThumbsUpIcon
|
||||
className="size-4"
|
||||
@@ -102,9 +101,9 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
/>
|
||||
</MessageAction>
|
||||
<MessageAction
|
||||
label={i18n.t('chat.actions.dislike')}
|
||||
label="Dislike"
|
||||
onClick={handleDislikeClick}
|
||||
tooltip={i18n.t('chat.actions.dislikeTooltip')}
|
||||
tooltip="Dislike this response"
|
||||
>
|
||||
<ThumbsDownIcon
|
||||
className="size-4"
|
||||
@@ -118,13 +117,13 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
<Dialog open={dislikeDialogOpen} onOpenChange={setDislikeDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{i18n.t('chat.actions.feedbackTitle')}</DialogTitle>
|
||||
<DialogTitle>What went wrong?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{i18n.t('chat.actions.feedbackDescription')}
|
||||
Help us improve by sharing what was wrong with this response.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder={i18n.t('chat.actions.feedbackPlaceholder')}
|
||||
placeholder="Add a comment (optional)"
|
||||
value={dislikeComment}
|
||||
onChange={(e) => setDislikeComment(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -135,11 +134,9 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleDislikeCancel}>
|
||||
{i18n.t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleDislikeSubmit}>
|
||||
{i18n.t('common.submit')}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDislikeSubmit}>Submit</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MessageSquare, MousePointer2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -38,20 +37,20 @@ export const ChatModeToggle: FC<ChatModeToggleProps> = ({
|
||||
{isAgentMode ? (
|
||||
<>
|
||||
<MousePointer2 className="h-3 w-3" />
|
||||
<span>{i18n.t('chat.mode.agent')}</span>
|
||||
<span>Agent Mode ON</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
<span>{i18n.t('chat.mode.chat')}</span>
|
||||
<span>Chat Mode ON</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[220px]">
|
||||
{isAgentMode
|
||||
? i18n.t('chat.mode.agentTooltip')
|
||||
: i18n.t('chat.mode.chatTooltip')}
|
||||
? 'AI can browse, click, and navigate'
|
||||
: 'AI can only read, cannot click or navigate'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { FileText, X } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
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={i18n.t('selectedText.removeTitle')}
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Check, Plug } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { i18n } from '#i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
BREADCRUMB_CONNECT_CLICKED_EVENT,
|
||||
@@ -39,11 +38,7 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
apiKeyUrl: string
|
||||
} | null>(null)
|
||||
const [resolvedText, setResolvedText] = useState(
|
||||
isLastMessage
|
||||
? ''
|
||||
: i18n.t('chat.connectApp.suggested', [
|
||||
(data.appName as string) ?? 'App',
|
||||
]),
|
||||
isLastMessage ? '' : `${(data.appName as string) ?? 'App'} suggested`,
|
||||
)
|
||||
|
||||
const { sendMessage } = useChatSessionContext()
|
||||
@@ -115,11 +110,9 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
managedServerDescription: '',
|
||||
})
|
||||
track(MANAGED_MCP_ADDED_EVENT, { server_name: appName })
|
||||
toast.success(
|
||||
i18n.t('chat.connectApp.connectedSuccess', [apiKeyServer.name]),
|
||||
)
|
||||
toast.success(`${apiKeyServer.name} connected successfully`)
|
||||
setApiKeyServer(null)
|
||||
setResolvedText(i18n.t('chat.connectApp.connected', [appName]))
|
||||
setResolvedText(`Connected ${appName}`)
|
||||
setPhase('resolved')
|
||||
sendMessage({
|
||||
text: `I've connected ${appName}, continue with the task`,
|
||||
@@ -134,7 +127,7 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
|
||||
const handleOAuthComplete = () => {
|
||||
track(BREADCRUMB_CONNECT_COMPLETED_EVENT, { app_name: appName })
|
||||
setResolvedText(i18n.t('chat.connectApp.connected', [appName]))
|
||||
setResolvedText(`Connected ${appName}`)
|
||||
setPhase('resolved')
|
||||
sendMessage({
|
||||
text: `I've connected ${appName}, continue with the task`,
|
||||
@@ -147,7 +140,7 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
if (!current.includes(appName)) {
|
||||
await declinedAppsStorage.setValue([...current, appName])
|
||||
}
|
||||
setResolvedText(i18n.t('chat.connectApp.continuedWithout', [appName]))
|
||||
setResolvedText(`Continuing without ${appName}`)
|
||||
setPhase('resolved')
|
||||
sendMessage({
|
||||
text: `Continue without connecting ${appName}, do it manually with browser automation`,
|
||||
@@ -172,20 +165,20 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
<Plug className="h-5 w-5 shrink-0 text-[var(--accent-orange)]" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{i18n.t('chat.connectApp.authorizeTitle', [appName])}
|
||||
Authorize {appName} in the opened tab
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{i18n.t('chat.connectApp.authorizeDescription')}
|
||||
Complete the sign-in flow, then click the button below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={handleOAuthComplete}>
|
||||
{i18n.t('chat.connectApp.authorizedButton', [appName])}
|
||||
I've authorized {appName}, continue
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleManual}>
|
||||
{i18n.t('chat.connectApp.skipButton')}
|
||||
Skip, do it manually
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,7 +192,7 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
<Plug className="h-5 w-5 shrink-0 text-[var(--accent-orange)]" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{i18n.t('chat.connectApp.connectForBetter', [appName])}
|
||||
Connect {appName} for better results
|
||||
</p>
|
||||
{reason && (
|
||||
<p className="mt-1 text-muted-foreground text-xs">{reason}</p>
|
||||
@@ -209,12 +202,10 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={handleConnect} disabled={connecting}>
|
||||
{connecting
|
||||
? i18n.t('chat.connectApp.connecting')
|
||||
: i18n.t('chat.connectApp.connectButton', [appName])}
|
||||
{connecting ? 'Connecting...' : `Connect ${appName}`}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleManual}>
|
||||
{i18n.t('chat.connectApp.doItManually')}
|
||||
Do it manually
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Clock, X } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
BREADCRUMB_SCHEDULE_CLICKED_EVENT,
|
||||
@@ -41,9 +40,7 @@ export const ScheduleSuggestionCard: FC<ScheduleSuggestionCardProps> = ({
|
||||
if (dismissed) return null
|
||||
|
||||
const scheduleLabel =
|
||||
scheduleType === 'daily'
|
||||
? i18n.t('chat.schedule.dailyAt', [scheduleTime])
|
||||
: i18n.t('chat.schedule.everyHour')
|
||||
scheduleType === 'daily' ? `daily at ${scheduleTime}` : 'every hour'
|
||||
|
||||
const handleSchedule = () => {
|
||||
track(BREADCRUMB_SCHEDULE_CLICKED_EVENT, {
|
||||
@@ -78,7 +75,7 @@ export const ScheduleSuggestionCard: FC<ScheduleSuggestionCardProps> = ({
|
||||
<div className="flex items-start gap-3 pr-6">
|
||||
<Clock className="h-5 w-5 shrink-0 text-[var(--accent-orange)]" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{i18n.t('chat.schedule.title')}</p>
|
||||
<p className="font-medium text-sm">Run this automatically?</p>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
“{suggestedName}” — I can run this {scheduleLabel}
|
||||
</p>
|
||||
@@ -87,10 +84,10 @@ export const ScheduleSuggestionCard: FC<ScheduleSuggestionCardProps> = ({
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={handleSchedule}>
|
||||
{i18n.t('chat.schedule.scheduleButton')}
|
||||
Schedule this task
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleDismiss}>
|
||||
{i18n.t('chat.schedule.maybeLater')}
|
||||
Maybe later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,6 @@ 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'
|
||||
@@ -71,8 +70,6 @@ export type ChatOrigin = 'sidepanel' | 'newtab'
|
||||
|
||||
export interface ChatSessionOptions {
|
||||
origin?: ChatOrigin
|
||||
/** When false, messages are queued until integrations finish syncing. */
|
||||
isIntegrationsSynced?: boolean
|
||||
}
|
||||
|
||||
const NEWTAB_SYSTEM_PROMPT = `IMPORTANT: The user is chatting from the New Tab page. When performing browser actions, ALWAYS open content in a NEW TAB rather than navigating the current tab. The user's new tab page should remain accessible.`
|
||||
@@ -166,34 +163,8 @@ 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
|
||||
@@ -237,12 +208,8 @@ 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()
|
||||
const provider = selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
|
||||
const currentMode = modeRef.current
|
||||
const enabledMcpServers = enabledMcpServersRef.current
|
||||
const customMcpServers = enabledCustomServersRef.current
|
||||
@@ -317,7 +284,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
: history.map((m) => `${m.role}: ${m.content}`).join('\n')
|
||||
: undefined
|
||||
|
||||
const result = {
|
||||
return {
|
||||
api: `${agentUrlRef.current}/chat`,
|
||||
body: {
|
||||
message,
|
||||
@@ -338,9 +305,6 @@ 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'
|
||||
@@ -352,21 +316,8 @@ 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
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -460,19 +411,6 @@ 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
|
||||
|
||||
@@ -483,47 +421,12 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
}
|
||||
}, [status])
|
||||
|
||||
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
|
||||
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
|
||||
const pendingMessageRef = useRef<{
|
||||
text: string
|
||||
action?: ChatAction
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
isIntegrationsSyncedRef.current = isIntegrationsSynced
|
||||
}, [isIntegrationsSynced])
|
||||
|
||||
// Flush pending message when integrations sync completes
|
||||
useEffect(() => {
|
||||
if (isIntegrationsSynced && pendingMessageRef.current) {
|
||||
const pending = pendingMessageRef.current
|
||||
pendingMessageRef.current = null
|
||||
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
|
||||
})
|
||||
}
|
||||
baseSendMessage({ text: pending.text })
|
||||
}
|
||||
}, [isIntegrationsSynced, baseSendMessage])
|
||||
|
||||
const sendMessage = (params: { text: string; action?: ChatAction }) => {
|
||||
track(MESSAGE_SENT_EVENT, {
|
||||
mode,
|
||||
provider_type: selectedLlmProvider?.type,
|
||||
model: selectedLlmProvider?.modelId,
|
||||
})
|
||||
|
||||
if (!isIntegrationsSyncedRef.current) {
|
||||
// Queue the message — will be sent when sync completes
|
||||
pendingMessageRef.current = params
|
||||
return
|
||||
}
|
||||
|
||||
if (params.action) {
|
||||
const action = params.action
|
||||
setTextToAction((prev) => {
|
||||
@@ -600,7 +503,6 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
providers,
|
||||
selectedProvider,
|
||||
isLoading: isLoadingProviders || isLoadingAgentUrl,
|
||||
isSyncing: !isIntegrationsSynced,
|
||||
isRestoringConversation,
|
||||
agentUrlError,
|
||||
chatError,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createContext, type FC, type ReactNode, useContext } from 'react'
|
||||
import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
|
||||
import {
|
||||
type ChatSessionOptions,
|
||||
useChatSession,
|
||||
@@ -12,11 +11,7 @@ const ChatSessionContext = createContext<ChatSessionContextValue | null>(null)
|
||||
export const ChatSessionProvider: FC<
|
||||
{ children: ReactNode } & ChatSessionOptions
|
||||
> = ({ children, ...options }) => {
|
||||
const { hasSynced } = useSyncRemoteIntegrations()
|
||||
const session = useChatSession({
|
||||
...options,
|
||||
isIntegrationsSynced: hasSynced,
|
||||
})
|
||||
const session = useChatSession(options)
|
||||
return (
|
||||
<ChatSessionContext.Provider value={session}>
|
||||
{children}
|
||||
|
||||
@@ -45,10 +45,6 @@ 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',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,8 +72,6 @@ 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' },
|
||||
}
|
||||
|
||||
function parseVersion(version: string): number[] {
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
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,30 +29,6 @@ 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 HUB_PROVIDER_ADDED_EVENT = 'settings.hub_provider.added'
|
||||
|
||||
@@ -80,10 +56,6 @@ export const SCHEDULED_TASK_DELETED_EVENT = 'settings.scheduled_task.deleted'
|
||||
/** @public */
|
||||
export const SCHEDULED_TASK_TOGGLED_EVENT = 'settings.scheduled_task.toggled'
|
||||
|
||||
/** @public */
|
||||
export const SCHEDULED_TASK_PROMPT_REFINED_EVENT =
|
||||
'settings.scheduled_task.prompt_refined'
|
||||
|
||||
/** @public */
|
||||
export const SCHEDULED_TASK_TESTED_EVENT = 'settings.scheduled_task.tested'
|
||||
|
||||
@@ -142,21 +114,6 @@ 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'
|
||||
|
||||
@@ -294,18 +251,3 @@ export const KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT =
|
||||
/** @public */
|
||||
export const KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT =
|
||||
'ui.rate_limit.moonshot_platform_clicked'
|
||||
|
||||
/** @public */
|
||||
export const SIDEPANEL_VOICE_RECORDING_STARTED_EVENT =
|
||||
'sidepanel.voice.recording_started'
|
||||
|
||||
/** @public */
|
||||
export const SIDEPANEL_VOICE_RECORDING_STOPPED_EVENT =
|
||||
'sidepanel.voice.recording_stopped'
|
||||
|
||||
/** @public */
|
||||
export const SIDEPANEL_VOICE_TRANSCRIPTION_COMPLETED_EVENT =
|
||||
'sidepanel.voice.transcription_completed'
|
||||
|
||||
/** @public */
|
||||
export const SIDEPANEL_VOICE_ERROR_EVENT = 'sidepanel.voice.error'
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
OpenAI,
|
||||
OpenRouter,
|
||||
} from '@lobehub/icons'
|
||||
import { Bot, Github } from 'lucide-react'
|
||||
import { Bot } from 'lucide-react'
|
||||
import type { FC, SVGProps } from 'react'
|
||||
import ProductLogoSvg from '@/assets/product_logo.svg'
|
||||
import type { ProviderType } from './types'
|
||||
@@ -32,8 +32,6 @@ const providerIconMap: Record<ProviderType, IconComponent | null> = {
|
||||
bedrock: Bedrock,
|
||||
browseros: null,
|
||||
moonshot: Kimi,
|
||||
'chatgpt-pro': OpenAI,
|
||||
'github-copilot': Github,
|
||||
}
|
||||
|
||||
interface ProviderIconProps {
|
||||
|
||||
@@ -20,24 +20,6 @@ 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: 'moonshot',
|
||||
name: 'Moonshot AI',
|
||||
@@ -147,8 +129,6 @@ 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: 'moonshot', label: 'Moonshot AI' },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
@@ -177,8 +157,6 @@ 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',
|
||||
moonshot: 'https://api.moonshot.ai/v1',
|
||||
anthropic: 'https://api.anthropic.com/v1',
|
||||
openai: 'https://api.openai.com/v1',
|
||||
|
||||
@@ -14,8 +14,6 @@ export type ProviderType =
|
||||
| 'bedrock'
|
||||
| 'browseros'
|
||||
| 'moonshot'
|
||||
| 'chatgpt-pro'
|
||||
| 'github-copilot'
|
||||
|
||||
/**
|
||||
* LLM Provider configuration
|
||||
@@ -58,10 +56,6 @@ 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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -158,7 +158,9 @@ export function useLlmProviders(): UseLlmProvidersReturn {
|
||||
// Fall back to first provider if defaultProviderId is stale/invalid
|
||||
const selectedProvider = useMemo(
|
||||
() =>
|
||||
providers.find((p) => p.id === defaultProviderId) ?? providers[0] ?? null,
|
||||
providers.find((p) => p.id === defaultProviderId) ??
|
||||
providers[0] ??
|
||||
null,
|
||||
[providers, defaultProviderId],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useGetMCPServersList } from '@/entrypoints/app/connect-mcp/useGetMCPServersList'
|
||||
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
|
||||
import { type McpServer, mcpServerStorage } from './mcpServerStorage'
|
||||
|
||||
export interface SyncStatus {
|
||||
/** True while the initial sync is in progress (fetching + writing to storage) */
|
||||
isSyncing: boolean
|
||||
/** True once the sync has completed at least once this session */
|
||||
hasSynced: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs remote Klavis integrations into local Chrome storage.
|
||||
*
|
||||
@@ -19,10 +12,8 @@ export interface SyncStatus {
|
||||
*
|
||||
* This hook detects authenticated remote integrations missing from local storage
|
||||
* and adds them so they appear in the UI (and can be disconnected).
|
||||
*
|
||||
* Returns sync status so consumers can gate behavior on sync completion.
|
||||
*/
|
||||
export function useSyncRemoteIntegrations(): SyncStatus {
|
||||
export function useSyncRemoteIntegrations() {
|
||||
const { data: userMCPIntegrations, isLoading: isIntegrationsLoading } =
|
||||
useGetUserMCPIntegrations()
|
||||
const { data: serversList } = useGetMCPServersList()
|
||||
@@ -30,26 +21,13 @@ export function useSyncRemoteIntegrations(): SyncStatus {
|
||||
const serversListRef = useRef(serversList)
|
||||
integrationsRef.current = userMCPIntegrations
|
||||
serversListRef.current = serversList
|
||||
const hasSyncedRef = useRef(false)
|
||||
const [syncState, setSyncState] = useState<SyncStatus>({
|
||||
isSyncing: true,
|
||||
hasSynced: false,
|
||||
})
|
||||
const hasSynced = useRef(false)
|
||||
|
||||
const integrationCount = userMCPIntegrations?.integrations?.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
// Still loading data — keep isSyncing: true
|
||||
if (isIntegrationsLoading) return
|
||||
|
||||
// No integrations at all — nothing to sync, mark done
|
||||
if (!integrationCount) {
|
||||
setSyncState({ isSyncing: false, hasSynced: true })
|
||||
return
|
||||
}
|
||||
|
||||
// Already synced this session
|
||||
if (hasSyncedRef.current) return
|
||||
if (isIntegrationsLoading || !integrationCount) return
|
||||
if (hasSynced.current) return
|
||||
|
||||
const integrations = integrationsRef.current?.integrations
|
||||
if (!integrations) return
|
||||
@@ -62,30 +40,26 @@ export function useSyncRemoteIntegrations(): SyncStatus {
|
||||
!localServers.some((s) => s.managedServerName === remote.name),
|
||||
)
|
||||
|
||||
if (missing.length > 0) {
|
||||
const catalog = serversListRef.current
|
||||
const newServers: McpServer[] = missing.map((integration) => {
|
||||
const catalogEntry = catalog?.servers.find(
|
||||
(s) => s.name === integration.name,
|
||||
)
|
||||
return {
|
||||
id: `${Date.now()}-${integration.name}`,
|
||||
displayName: integration.name,
|
||||
type: 'managed',
|
||||
managedServerName: integration.name,
|
||||
managedServerDescription: catalogEntry?.description ?? '',
|
||||
}
|
||||
})
|
||||
if (missing.length === 0) return
|
||||
|
||||
await mcpServerStorage.setValue([...localServers, ...newServers])
|
||||
}
|
||||
const catalog = serversListRef.current
|
||||
const newServers: McpServer[] = missing.map((integration) => {
|
||||
const catalogEntry = catalog?.servers.find(
|
||||
(s) => s.name === integration.name,
|
||||
)
|
||||
return {
|
||||
id: `${Date.now()}-${integration.name}`,
|
||||
displayName: integration.name,
|
||||
type: 'managed',
|
||||
managedServerName: integration.name,
|
||||
managedServerDescription: catalogEntry?.description ?? '',
|
||||
}
|
||||
})
|
||||
|
||||
hasSyncedRef.current = true
|
||||
setSyncState({ isSyncing: false, hasSynced: true })
|
||||
await mcpServerStorage.setValue([...localServers, ...newServers])
|
||||
}
|
||||
|
||||
hasSynced.current = true
|
||||
syncMissing()
|
||||
}, [isIntegrationsLoading, integrationCount])
|
||||
|
||||
return syncState
|
||||
}
|
||||
|
||||
@@ -23,4 +23,4 @@ type ScheduleMessagesProtocol = {
|
||||
const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<ScheduleMessagesProtocol>()
|
||||
|
||||
export { onMessage as onScheduleMessage, sendMessage as sendScheduleMessage }
|
||||
export { sendMessage as sendScheduleMessage, onMessage as onScheduleMessage }
|
||||
|
||||
@@ -12,4 +12,4 @@ type ServerMessagesProtocol = {
|
||||
const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<ServerMessagesProtocol>()
|
||||
|
||||
export { onMessage as onServerMessage, sendMessage as sendServerMessage }
|
||||
export { sendMessage as sendServerMessage, onMessage as onServerMessage }
|
||||
|
||||
@@ -12,6 +12,6 @@ const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<OpenSidePanelWithSearchParams>()
|
||||
|
||||
export {
|
||||
onMessage as onOpenSidePanelWithSearch,
|
||||
sendMessage as openSidePanelWithSearch,
|
||||
onMessage as onOpenSidePanelWithSearch,
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ interface ChatServerRequest {
|
||||
windowId?: number
|
||||
activeTab?: ActiveTab
|
||||
signal?: AbortSignal
|
||||
providerId?: string
|
||||
}
|
||||
|
||||
interface ChatServerResponse {
|
||||
@@ -76,23 +75,11 @@ const getDefaultProvider = async (): Promise<LlmProviderConfig | null> => {
|
||||
return defaultProvider ?? providers[0] ?? null
|
||||
}
|
||||
|
||||
// Resolve provider by ID, falling back to global default
|
||||
const resolveProvider = async (
|
||||
providerId?: string,
|
||||
): Promise<LlmProviderConfig> => {
|
||||
if (providerId) {
|
||||
const providers = await providersStorage.getValue()
|
||||
const match = providers?.find((p) => p.id === providerId)
|
||||
if (match) return match
|
||||
}
|
||||
return (await getDefaultProvider()) ?? createDefaultBrowserOSProvider()
|
||||
}
|
||||
|
||||
export async function getChatServerResponse(
|
||||
request: ChatServerRequest,
|
||||
): Promise<ChatServerResponse> {
|
||||
const agentServerUrl = await getAgentServerUrl()
|
||||
const provider = await resolveProvider(request.providerId)
|
||||
const provider = (await getDefaultProvider()) ?? createDefaultBrowserOSProvider()
|
||||
const conversationId = request.conversationId ?? crypto.randomUUID()
|
||||
const personalization = await personalizationStorage.getValue()
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ export const GetScheduledJobsByProfileIdDocument = graphql(`
|
||||
scheduleTime
|
||||
scheduleInterval
|
||||
enabled
|
||||
llmProviderId
|
||||
createdAt
|
||||
updatedAt
|
||||
lastRunAt
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import {
|
||||
createDefaultBrowserOSProvider,
|
||||
defaultProviderIdStorage,
|
||||
providersStorage,
|
||||
} from '@/lib/llm-providers/storage'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
|
||||
const resolveProvider = async (
|
||||
providerId?: string,
|
||||
): Promise<LlmProviderConfig> => {
|
||||
const providers = await providersStorage.getValue()
|
||||
if (providerId && providers?.length) {
|
||||
const match = providers.find((p) => p.id === providerId)
|
||||
if (match) return match
|
||||
}
|
||||
if (providers?.length) {
|
||||
const defaultProviderId = await defaultProviderIdStorage.getValue()
|
||||
const defaultProvider = providers.find((p) => p.id === defaultProviderId)
|
||||
if (defaultProvider) return defaultProvider
|
||||
if (providers[0]) return providers[0]
|
||||
}
|
||||
return createDefaultBrowserOSProvider()
|
||||
}
|
||||
|
||||
interface RefinePromptResponse {
|
||||
success: boolean
|
||||
refined?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function refinePrompt(params: {
|
||||
prompt: string
|
||||
name: string
|
||||
providerId?: string
|
||||
}): Promise<string> {
|
||||
const agentServerUrl = await getAgentServerUrl()
|
||||
const provider = await resolveProvider(params.providerId)
|
||||
|
||||
const response = await fetch(`${agentServerUrl}/refine-prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: params.prompt,
|
||||
name: params.name,
|
||||
provider: provider.type,
|
||||
model: provider.modelId ?? 'default',
|
||||
apiKey: provider.apiKey,
|
||||
baseUrl: provider.baseUrl,
|
||||
resourceName: provider.resourceName,
|
||||
accessKeyId: provider.accessKeyId,
|
||||
secretAccessKey: provider.secretAccessKey,
|
||||
region: provider.region,
|
||||
sessionToken: provider.sessionToken,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response
|
||||
.json()
|
||||
.catch(() => null)) as RefinePromptResponse | null
|
||||
throw new Error(errorData?.message ?? `Request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as RefinePromptResponse
|
||||
if (!data.success || !data.refined) {
|
||||
throw new Error(data.message ?? 'Failed to refine prompt')
|
||||
}
|
||||
|
||||
return data.refined
|
||||
}
|
||||
@@ -6,7 +6,6 @@ export interface ScheduledJob {
|
||||
scheduleTime?: string
|
||||
scheduleInterval?: number
|
||||
enabled: boolean
|
||||
providerId?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastRunAt?: string
|
||||
|
||||
@@ -19,7 +19,6 @@ type RemoteScheduledJob = {
|
||||
scheduleTime: string | null
|
||||
scheduleInterval: number | null
|
||||
enabled: boolean
|
||||
llmProviderId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastRunAt: string | null
|
||||
@@ -33,7 +32,6 @@ function toComparable(job: ScheduledJob) {
|
||||
...data,
|
||||
scheduleTime: data.scheduleTime ?? null,
|
||||
scheduleInterval: data.scheduleInterval ?? null,
|
||||
providerId: data.providerId ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +43,6 @@ function remoteToComparable(job: RemoteScheduledJob) {
|
||||
scheduleTime: job.scheduleTime,
|
||||
scheduleInterval: job.scheduleInterval,
|
||||
enabled: job.enabled,
|
||||
providerId: job.llmProviderId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +59,6 @@ function remoteToLocal(remote: RemoteScheduledJob): ScheduledJob {
|
||||
scheduleTime: remote.scheduleTime ?? undefined,
|
||||
scheduleInterval: remote.scheduleInterval ?? undefined,
|
||||
enabled: remote.enabled,
|
||||
providerId: remote.llmProviderId ?? undefined,
|
||||
createdAt: normalizeTimestamp(remote.createdAt),
|
||||
updatedAt: normalizeTimestamp(remote.updatedAt),
|
||||
lastRunAt: remote.lastRunAt
|
||||
@@ -167,7 +163,6 @@ export async function syncSchedulesToBackend(
|
||||
scheduleTime: job.scheduleTime ?? null,
|
||||
scheduleInterval: job.scheduleInterval ?? null,
|
||||
enabled: job.enabled,
|
||||
llmProviderId: job.providerId ?? null,
|
||||
lastRunAt: job.lastRunAt
|
||||
? new Date(job.lastRunAt).toISOString()
|
||||
: null,
|
||||
@@ -187,7 +182,6 @@ export async function syncSchedulesToBackend(
|
||||
scheduleTime: job.scheduleTime ?? null,
|
||||
scheduleInterval: job.scheduleInterval ?? null,
|
||||
enabled: job.enabled,
|
||||
llmProviderId: job.providerId ?? null,
|
||||
createdAt: new Date(job.createdAt).toISOString(),
|
||||
updatedAt: job.updatedAt || new Date().toISOString(),
|
||||
lastRunAt: job.lastRunAt
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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: {} })
|
||||
@@ -1,134 +0,0 @@
|
||||
extName: BrowserOS-Assistent
|
||||
extActionTitle: BrowserOS fragen
|
||||
chat:
|
||||
input:
|
||||
placeholderAgent: Was soll ich tun?
|
||||
placeholderChat: Frage zu dieser Seite...
|
||||
placeholderTranscribing: Transkribieren...
|
||||
send: Senden
|
||||
stop: Stop
|
||||
stopRecording: Aufnahme stoppen
|
||||
transcribing: Transkribieren
|
||||
voiceInput: Spracheingabe
|
||||
mode:
|
||||
agent: Agent-Modus AN
|
||||
chat: Chat-Modus AN
|
||||
agentTooltip: KI kann browsen, klicken und navigieren
|
||||
chatTooltip: KI kann nur lesen, nicht klicken oder navigieren
|
||||
empty:
|
||||
agentTitle: Agent zu Ihren Diensten
|
||||
agentSubtitle: KI automatisiert Aufgaben und browset für Sie
|
||||
chatTitle: Mit dieser Seite chatten
|
||||
chatSubtitle: Fragen zur aktuellen Seite oder einem beliebigen Thema stellen
|
||||
header:
|
||||
changeProvider: KI-Anbieter wechseln
|
||||
newConversation: Neues Gespräch
|
||||
chatHistory: Chat-Verlauf
|
||||
starOnGithub: Auf Github starren
|
||||
settings: Einstellungen
|
||||
error:
|
||||
dailyLimitTitle: Tageslimit erreicht
|
||||
connectionFailedTitle: Verbindung fehlgeschlagen
|
||||
genericTitle: Fehler aufgetreten
|
||||
connectionMessage: Verbindung zu BrowserOS-Agent nicht möglich. Folgen Sie den Anweisungen unten.
|
||||
rateLimitMessage: Eigenen API-Key hinzufügen für unbegrenzte Nutzung.
|
||||
troubleshootingLink: Fehlerbehebung anzeigen
|
||||
kimiApiKeyLink: Anleitung zum Kimi API-Key
|
||||
getApiKey: API-Key erhalten
|
||||
tryAgain: Erneut versuchen
|
||||
actions:
|
||||
copy: Kopieren
|
||||
copyToClipboard: In Zwischenablage kopieren
|
||||
feedbackSubmitted: Feedback gesendet
|
||||
like: Gefällt mir
|
||||
likeTooltip: Antwort gefällt mir
|
||||
dislike: Gefällt nicht
|
||||
dislikeTooltip: Antwort gefällt nicht
|
||||
feedbackTitle: Was ist schiefgelaufen?
|
||||
feedbackDescription: Teilen Sie uns mit, was an dieser Antwort nicht stimmte.
|
||||
feedbackPlaceholder: Kommentar (optional)
|
||||
schedule:
|
||||
title: Automatisch ausführen?
|
||||
dailyAt: täglich um $1
|
||||
everyHour: jede Stunde
|
||||
scheduleButton: Aufgabe planen
|
||||
maybeLater: Später vielleicht
|
||||
connectApp:
|
||||
connecting: Verbinden...
|
||||
connectButton: $1 verbinden
|
||||
authorizeTitle: $1 im geöffneten Tab autorisieren
|
||||
authorizeDescription: Anmeldung abschließen, dann Button klicken.
|
||||
authorizedButton: $1 autorisiert, weiter
|
||||
skipButton: Überspringen, manuell
|
||||
suggested: $1 vorgeschlagen
|
||||
connected: $1 verbunden
|
||||
continuedWithout: Ohne $1 fortfahren
|
||||
connectForBetter: $1 für bessere Ergebnisse verbinden
|
||||
doItManually: Manuell erledigen
|
||||
connectedSuccess: $1 erfolgreich verbunden
|
||||
connectFailed: Verbindung zu $1 fehlgeschlagen
|
||||
footer:
|
||||
attachTabs: Tabs anhängen (@)
|
||||
selectWorkspace: Arbeitsbereich wählen
|
||||
connectApps: Apps verbinden
|
||||
selectedText:
|
||||
removeTitle: Textauswahl entfernen
|
||||
attachedTabs:
|
||||
removeTab: Tab entfernen
|
||||
newtab:
|
||||
search:
|
||||
placeholder: BrowserOS fragen oder $1 suchen...
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
selectedTab: Tab
|
||||
appsNew: Neu!
|
||||
appsTooltip: Direkt verbundene Apps liefern genauere und schnellere Antworten!
|
||||
addWorkspace: Arbeitsbereich hinzufügen
|
||||
tip:
|
||||
label: "Tipp:"
|
||||
nextTip: Nächster Tipp
|
||||
dismiss: Schließen
|
||||
signIn:
|
||||
title: Daten synchronisieren
|
||||
description: Anmelden zum Synchronisieren des Chat-Verlaufs.
|
||||
dontAskAgain: Nicht wieder fragen
|
||||
signInButton: Anmelden
|
||||
importData:
|
||||
title: Daten importieren
|
||||
description: Lesezeichen, Verlauf und Passwörter aus Chrome importieren.
|
||||
dontShowAgain: Nicht mehr anzeigen
|
||||
openSettings: Importeinstellungen öffnen
|
||||
shortcuts:
|
||||
title: Tastenkürzel
|
||||
description: Diese Kürzel für schnellere Navigation in BrowserOS
|
||||
moreComingSoon: Weitere Kürzel folgen
|
||||
sidebar:
|
||||
nav:
|
||||
home: Start
|
||||
connectApps: Apps verbinden
|
||||
scheduledTasks: Geplante Aufgaben
|
||||
workflows: Workflows
|
||||
skills: Skills
|
||||
memory: Speicher
|
||||
soul: Seele
|
||||
settings: Einstellungen
|
||||
footer:
|
||||
about: Über BrowserOS
|
||||
shortcuts: Kürzel
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
personal: Persönlich
|
||||
updateProfile: Profil aktualisieren
|
||||
signOut: Abmelden
|
||||
signIn: Anmelden
|
||||
common:
|
||||
cancel: Abbrechen
|
||||
submit: Absenden
|
||||
save: Speichern
|
||||
delete: Löschen
|
||||
edit: Bearbeiten
|
||||
close: Schließen
|
||||
loading: Laden...
|
||||
error: Fehler aufgetreten
|
||||
apps: Apps
|
||||
tabs: Tabs
|
||||
@@ -1,169 +0,0 @@
|
||||
# Extension manifest
|
||||
extName: BrowserOS Assistant
|
||||
extActionTitle: Ask BrowserOS
|
||||
|
||||
# Chat input
|
||||
chat:
|
||||
input:
|
||||
placeholderAgent: What should I do?
|
||||
placeholderChat: Ask about this page...
|
||||
placeholderTranscribing: Transcribing...
|
||||
send: Send
|
||||
stop: Stop
|
||||
stopRecording: Stop recording
|
||||
transcribing: Transcribing
|
||||
voiceInput: Voice input
|
||||
|
||||
# Chat mode toggle
|
||||
mode:
|
||||
agent: Agent Mode ON
|
||||
chat: Chat Mode ON
|
||||
agentTooltip: AI can browse, click, and navigate
|
||||
chatTooltip: AI can only read, cannot click or navigate
|
||||
|
||||
# Chat empty state
|
||||
empty:
|
||||
agentTitle: Agent at your service
|
||||
agentSubtitle: Let AI automate tasks and browse for you
|
||||
chatTitle: Chat with this page
|
||||
chatSubtitle: Ask questions about the current page or any topic
|
||||
|
||||
# Chat header
|
||||
header:
|
||||
changeProvider: Change AI Provider
|
||||
newConversation: New conversation
|
||||
chatHistory: Chat history
|
||||
starOnGithub: Star on Github
|
||||
settings: Settings
|
||||
|
||||
# Chat errors
|
||||
error:
|
||||
dailyLimitTitle: Daily limit reached
|
||||
connectionFailedTitle: Connection failed
|
||||
genericTitle: Something went wrong
|
||||
connectionMessage: "Unable to connect to BrowserOS agent. Follow below instructions."
|
||||
rateLimitMessage: Add your own API key for unlimited usage.
|
||||
troubleshootingLink: View troubleshooting guide
|
||||
kimiApiKeyLink: Learn how to get a Kimi API key
|
||||
getApiKey: get your API key
|
||||
tryAgain: Try again
|
||||
|
||||
# Chat message actions
|
||||
actions:
|
||||
copy: Copy
|
||||
copyToClipboard: Copy to clipboard
|
||||
feedbackSubmitted: Feedback submitted
|
||||
like: Like
|
||||
likeTooltip: Like this response
|
||||
dislike: Dislike
|
||||
dislikeTooltip: Dislike this response
|
||||
feedbackTitle: What went wrong?
|
||||
feedbackDescription: Help us improve by sharing what was wrong with this response.
|
||||
feedbackPlaceholder: Add a comment (optional)
|
||||
|
||||
# Schedule suggestion card
|
||||
schedule:
|
||||
title: Run this automatically?
|
||||
dailyAt: "daily at $1"
|
||||
everyHour: every hour
|
||||
scheduleButton: Schedule this task
|
||||
maybeLater: Maybe later
|
||||
|
||||
# Connect app card
|
||||
connectApp:
|
||||
connecting: Connecting...
|
||||
connectButton: "Connect $1"
|
||||
authorizeTitle: "Authorize $1 in the opened tab"
|
||||
authorizeDescription: Complete the sign-in flow, then click the button below.
|
||||
authorizedButton: "I've authorized $1, continue"
|
||||
skipButton: "Skip, do it manually"
|
||||
suggested: "$1 suggested"
|
||||
connected: "Connected $1"
|
||||
continuedWithout: "Continuing without $1"
|
||||
connectForBetter: "Connect $1 for better results"
|
||||
doItManually: Do it manually
|
||||
connectedSuccess: "$1 connected successfully"
|
||||
connectFailed: "Failed to connect $1"
|
||||
|
||||
# Chat footer
|
||||
footer:
|
||||
attachTabs: Attach tabs (@)
|
||||
selectWorkspace: Select workspace folder
|
||||
connectApps: Connect apps
|
||||
|
||||
# Selected text card
|
||||
selectedText:
|
||||
removeTitle: Remove selected text
|
||||
|
||||
# Attached tabs
|
||||
attachedTabs:
|
||||
removeTab: Remove tab
|
||||
|
||||
# Newtab page
|
||||
newtab:
|
||||
search:
|
||||
placeholder: "Ask BrowserOS or search $1..."
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
selectedTab: Tab
|
||||
appsNew: "New!"
|
||||
appsTooltip: Apps directly connected will have more accurate and faster responses for your queries!
|
||||
addWorkspace: Add workspace
|
||||
tip:
|
||||
label: "Tip:"
|
||||
nextTip: Next tip
|
||||
dismiss: Dismiss
|
||||
|
||||
# Sign in hint
|
||||
signIn:
|
||||
title: Sync your data
|
||||
description: Sign in to sync conversation history to the cloud.
|
||||
dontAskAgain: Don't ask again
|
||||
signInButton: Sign in
|
||||
|
||||
# Import data hint
|
||||
importData:
|
||||
title: Import your data
|
||||
description: "Bring bookmarks, history, and passwords from Chrome."
|
||||
dontShowAgain: Don't show this again
|
||||
openSettings: Open Import Settings
|
||||
|
||||
# Shortcuts dialog
|
||||
shortcuts:
|
||||
title: Keyboard Shortcuts
|
||||
description: Use these shortcuts to navigate BrowserOS faster
|
||||
moreComingSoon: More shortcuts coming soon
|
||||
|
||||
# Sidebar navigation
|
||||
sidebar:
|
||||
nav:
|
||||
home: Home
|
||||
connectApps: Connect Apps
|
||||
scheduledTasks: Scheduled Tasks
|
||||
workflows: Workflows
|
||||
skills: Skills
|
||||
memory: Memory
|
||||
soul: Soul
|
||||
settings: Settings
|
||||
footer:
|
||||
about: About BrowserOS
|
||||
shortcuts: Shortcuts
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
personal: Personal
|
||||
updateProfile: Update Profile
|
||||
signOut: Sign out
|
||||
signIn: Sign in
|
||||
|
||||
# Common / shared
|
||||
common:
|
||||
cancel: Cancel
|
||||
submit: Submit
|
||||
save: Save
|
||||
delete: Delete
|
||||
edit: Edit
|
||||
close: Close
|
||||
loading: Loading...
|
||||
error: Something went wrong
|
||||
apps: Apps
|
||||
tabs: Tabs
|
||||
@@ -1,134 +0,0 @@
|
||||
extName: Asistente BrowserOS
|
||||
extActionTitle: Pregunta a BrowserOS
|
||||
chat:
|
||||
input:
|
||||
placeholderAgent: ¿Qué debo hacer?
|
||||
placeholderChat: Pregunta sobre esta página...
|
||||
placeholderTranscribing: Transcribiendo...
|
||||
send: Enviar
|
||||
stop: Detener
|
||||
stopRecording: Detener grabación
|
||||
transcribing: Transcribiendo
|
||||
voiceInput: Entrada de voz
|
||||
mode:
|
||||
agent: Modo Agente ON
|
||||
chat: Modo Chat ON
|
||||
agentTooltip: La IA puede navegar e interactuar
|
||||
chatTooltip: Solo lectura, sin interacción
|
||||
empty:
|
||||
agentTitle: Agente a su servicio
|
||||
agentSubtitle: Deja que la IA automatice y navegue por ti
|
||||
chatTitle: Chatea con esta página
|
||||
chatSubtitle: Pregunta sobre la página actual o cualquier tema
|
||||
header:
|
||||
changeProvider: Cambiar proveedor de IA
|
||||
newConversation: Nueva conversación
|
||||
chatHistory: Historial
|
||||
starOnGithub: Star en GitHub
|
||||
settings: Ajustes
|
||||
error:
|
||||
dailyLimitTitle: Límite diario alcanzado
|
||||
connectionFailedTitle: Conexión fallida
|
||||
genericTitle: Algo salió mal
|
||||
connectionMessage: No se pudo conectar al agente. Sigue las instrucciones.
|
||||
rateLimitMessage: Añade tu clave API para uso ilimitado.
|
||||
troubleshootingLink: Ver guía de solución
|
||||
kimiApiKeyLink: Cómo obtener clave API de Kimi
|
||||
getApiKey: obtener tu clave API
|
||||
tryAgain: Reintentar
|
||||
actions:
|
||||
copy: Copiar
|
||||
copyToClipboard: Copiar al portapapeles
|
||||
feedbackSubmitted: Feedback enviado
|
||||
like: Me gusta
|
||||
likeTooltip: Me gusta esta respuesta
|
||||
dislike: No me gusta
|
||||
dislikeTooltip: No me gusta esta respuesta
|
||||
feedbackTitle: ¿Qué salió mal?
|
||||
feedbackDescription: Cuéntanos qué salió mal para mejorar.
|
||||
feedbackPlaceholder: Añade un comentario (opcional)
|
||||
schedule:
|
||||
title: ¿Ejecutar automáticamente?
|
||||
dailyAt: diariamente a las $1
|
||||
everyHour: cada hora
|
||||
scheduleButton: Programar tarea
|
||||
maybeLater: Más tarde
|
||||
connectApp:
|
||||
connecting: Conectando...
|
||||
connectButton: Conectar $1
|
||||
authorizeTitle: Autoriza $1 en la pestaña abierta
|
||||
authorizeDescription: Completa el inicio de sesión y haz clic abajo.
|
||||
authorizedButton: He autorizado $1, continuar
|
||||
skipButton: Omitir, hacer manualmente
|
||||
suggested: $1 sugerido
|
||||
connected: $1 conectado
|
||||
continuedWithout: Continuando sin $1
|
||||
connectForBetter: Conecta $1 para mejores resultados
|
||||
doItManually: Hacerlo manualmente
|
||||
connectedSuccess: $1 conectado con éxito
|
||||
connectFailed: Error al conectar $1
|
||||
footer:
|
||||
attachTabs: Adjuntar pestañas (@)
|
||||
selectWorkspace: Seleccionar carpeta
|
||||
connectApps: Conectar apps
|
||||
selectedText:
|
||||
removeTitle: Eliminar texto seleccionado
|
||||
attachedTabs:
|
||||
removeTab: Quitar pestaña
|
||||
newtab:
|
||||
search:
|
||||
placeholder: Pregunta a BrowserOS o busca $1...
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
selectedTab: Pestaña
|
||||
appsNew: ¡Nuevo!
|
||||
appsTooltip: Las apps conectadas ofrecen respuestas más precisas y rápidas
|
||||
addWorkspace: Añadir espacio de trabajo
|
||||
tip:
|
||||
label: "Consejo:"
|
||||
nextTip: Siguiente consejo
|
||||
dismiss: Descartar
|
||||
signIn:
|
||||
title: Sincroniza tus datos
|
||||
description: Inicia sesión para sincronizar el historial en la nube.
|
||||
dontAskAgain: No preguntar de nuevo
|
||||
signInButton: Iniciar sesión
|
||||
importData:
|
||||
title: Importar tus datos
|
||||
description: Importa marcadores, historial y contraseñas de Chrome.
|
||||
dontShowAgain: No mostrar de nuevo
|
||||
openSettings: Abrir ajustes de importación
|
||||
shortcuts:
|
||||
title: Atajos de teclado
|
||||
description: Usa estos atajos para navegar más rápido
|
||||
moreComingSoon: Más atajos pronto
|
||||
sidebar:
|
||||
nav:
|
||||
home: Inicio
|
||||
connectApps: Conectar Apps
|
||||
scheduledTasks: Tareas programadas
|
||||
workflows: Flujos
|
||||
skills: Habilidades
|
||||
memory: Memoria
|
||||
soul: Alma
|
||||
settings: Ajustes
|
||||
footer:
|
||||
about: Acerca de BrowserOS
|
||||
shortcuts: Atajos
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
personal: Personal
|
||||
updateProfile: Actualizar perfil
|
||||
signOut: Cerrar sesión
|
||||
signIn: Iniciar sesión
|
||||
common:
|
||||
cancel: Cancelar
|
||||
submit: Enviar
|
||||
save: Guardar
|
||||
delete: Eliminar
|
||||
edit: Editar
|
||||
close: Cerrar
|
||||
loading: Cargando...
|
||||
error: Algo salió mal
|
||||
apps: Apps
|
||||
tabs: Pestañas
|
||||
@@ -1,134 +0,0 @@
|
||||
extName: Assistant BrowserOS
|
||||
extActionTitle: Demander à BrowserOS
|
||||
chat:
|
||||
input:
|
||||
placeholderAgent: Que dois-je faire ?
|
||||
placeholderChat: Demandez sur cette page...
|
||||
placeholderTranscribing: Transcription...
|
||||
send: Envoyer
|
||||
stop: Arrêter
|
||||
stopRecording: Arrêter l'enregistrement
|
||||
transcribing: Transcription
|
||||
voiceInput: Saisie vocale
|
||||
mode:
|
||||
agent: Mode agent activé
|
||||
chat: Mode chat activé
|
||||
agentTooltip: L'IA peut naviguer, cliquer et se déplacer
|
||||
chatTooltip: L'IA peut lire mais pas cliquer ni naviguer
|
||||
empty:
|
||||
agentTitle: Agent à votre service
|
||||
agentSubtitle: Laissez l'IA automatiser et naviguer pour vous
|
||||
chatTitle: Discuter avec cette page
|
||||
chatSubtitle: Posez des questions sur cette page ou tout sujet
|
||||
header:
|
||||
changeProvider: Changer fournisseur IA
|
||||
newConversation: Nouvelle conversation
|
||||
chatHistory: Historique
|
||||
starOnGithub: Étoiler sur Github
|
||||
settings: Paramètres
|
||||
error:
|
||||
dailyLimitTitle: Limite quotidienne atteinte
|
||||
connectionFailedTitle: Connexion échouée
|
||||
genericTitle: Une erreur s'est produite
|
||||
connectionMessage: Impossible de connecter l'agent BrowserOS. Suivez les instructions ci-dessous.
|
||||
rateLimitMessage: Ajoutez votre propre clé API pour un usage illimité.
|
||||
troubleshootingLink: Voir le guide de dépannage
|
||||
kimiApiKeyLink: Comment obtenir une clé API Kimi
|
||||
getApiKey: obtenir votre clé API
|
||||
tryAgain: Réessayer
|
||||
actions:
|
||||
copy: Copier
|
||||
copyToClipboard: Copier dans le presse-papiers
|
||||
feedbackSubmitted: Feedback envoyé
|
||||
like: Utile
|
||||
likeTooltip: Cette réponse est utile
|
||||
dislike: Inutile
|
||||
dislikeTooltip: Cette réponse est inutile
|
||||
feedbackTitle: Quel est le problème ?
|
||||
feedbackDescription: Aidez-nous à nous améliorer en indiquant le problème.
|
||||
feedbackPlaceholder: Commentaire (facultatif)
|
||||
schedule:
|
||||
title: Exécuter automatiquement ?
|
||||
dailyAt: tous les jours à $1
|
||||
everyHour: toutes les heures
|
||||
scheduleButton: Planifier cette tâche
|
||||
maybeLater: Plus tard
|
||||
connectApp:
|
||||
connecting: Connexion...
|
||||
connectButton: Connecter $1
|
||||
authorizeTitle: Autoriser $1 dans l'onglet ouvert
|
||||
authorizeDescription: Terminez la connexion, puis cliquez ci-dessous.
|
||||
authorizedButton: J'ai autorisé $1, continuer
|
||||
skipButton: Passer, faire manuellement
|
||||
suggested: $1 suggéré
|
||||
connected: $1 connecté
|
||||
continuedWithout: Continuer sans $1
|
||||
connectForBetter: Connectez $1 pour de meilleurs résultats
|
||||
doItManually: Faire manuellement
|
||||
connectedSuccess: $1 connecté avec succès
|
||||
connectFailed: Impossible de connecter $1
|
||||
footer:
|
||||
attachTabs: Joindre onglets (@)
|
||||
selectWorkspace: Choisir l'espace de travail
|
||||
connectApps: Connecter apps
|
||||
selectedText:
|
||||
removeTitle: Supprimer le texte sélectionné
|
||||
attachedTabs:
|
||||
removeTab: Supprimer l'onglet
|
||||
newtab:
|
||||
search:
|
||||
placeholder: Demandez à BrowserOS ou cherchez $1...
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
selectedTab: Onglet
|
||||
appsNew: Nouveau !
|
||||
appsTooltip: Les apps connectées directement offrent des réponses plus précises et rapides !
|
||||
addWorkspace: Ajouter un espace de travail
|
||||
tip:
|
||||
label: "Astuce :"
|
||||
nextTip: Astuce suivante
|
||||
dismiss: Ignorer
|
||||
signIn:
|
||||
title: Synchroniser vos données
|
||||
description: Connectez-vous pour synchroniser l'historique dans le cloud.
|
||||
dontAskAgain: Ne plus demander
|
||||
signInButton: Se connecter
|
||||
importData:
|
||||
title: Importer vos données
|
||||
description: Importez marque-pages, historique et mots de passe depuis Chrome.
|
||||
dontShowAgain: Ne plus afficher
|
||||
openSettings: Ouvrir les paramètres d'importation
|
||||
shortcuts:
|
||||
title: Raccourcis clavier
|
||||
description: Utilisez ces raccourcis pour naviguer plus vite dans BrowserOS
|
||||
moreComingSoon: Plus de raccourcis bientôt
|
||||
sidebar:
|
||||
nav:
|
||||
home: Accueil
|
||||
connectApps: Apps connectées
|
||||
scheduledTasks: Tâches planifiées
|
||||
workflows: Workflows
|
||||
skills: Compétences
|
||||
memory: Mémoire
|
||||
soul: Âme
|
||||
settings: Paramètres
|
||||
footer:
|
||||
about: À propos de BrowserOS
|
||||
shortcuts: Raccourcis
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
personal: Personnel
|
||||
updateProfile: Mettre à jour le profil
|
||||
signOut: Déconnexion
|
||||
signIn: Connexion
|
||||
common:
|
||||
cancel: Annuler
|
||||
submit: Valider
|
||||
save: Enregistrer
|
||||
delete: Supprimer
|
||||
edit: Modifier
|
||||
close: Fermer
|
||||
loading: Chargement...
|
||||
error: Une erreur s'est produite
|
||||
apps: Apps
|
||||
tabs: Onglets
|
||||
@@ -1,134 +0,0 @@
|
||||
extName: BrowserOS アシスタント
|
||||
extActionTitle: BrowserOS に質問
|
||||
chat:
|
||||
input:
|
||||
placeholderAgent: 何をしますか?
|
||||
placeholderChat: このページについて...
|
||||
placeholderTranscribing: 文字起こし中...
|
||||
send: 送信
|
||||
stop: 停止
|
||||
stopRecording: 録音停止
|
||||
transcribing: 文字起こし中
|
||||
voiceInput: 音声入力
|
||||
mode:
|
||||
agent: エージェントモードON
|
||||
chat: チャットモードON
|
||||
agentTooltip: 閲覧・クリック・ナビゲート可能
|
||||
chatTooltip: 閲覧のみ、クリック・ナビゲート不可
|
||||
empty:
|
||||
agentTitle: エージェントが対応します
|
||||
agentSubtitle: AIにタスク自動化と閲覧をお任せ
|
||||
chatTitle: このページとチャット
|
||||
chatSubtitle: 現在のページやトピックについて質問
|
||||
header:
|
||||
changeProvider: AIプロバイダー変更
|
||||
newConversation: 新規会話
|
||||
chatHistory: 履歴
|
||||
starOnGithub: GitHubでスター
|
||||
settings: 設定
|
||||
error:
|
||||
dailyLimitTitle: 1日の制限に達しました
|
||||
connectionFailedTitle: 接続失敗
|
||||
genericTitle: エラーが発生しました
|
||||
connectionMessage: BrowserOSエージェントに接続できません。以下の手順に従ってください。
|
||||
rateLimitMessage: 無制限利用にはAPIキーを追加してください。
|
||||
troubleshootingLink: トラブルシューティングを見る
|
||||
kimiApiKeyLink: Kimi APIキーの取得方法
|
||||
getApiKey: APIキーを取得
|
||||
tryAgain: 再試行
|
||||
actions:
|
||||
copy: コピー
|
||||
copyToClipboard: クリップボードにコピー
|
||||
feedbackSubmitted: フィードバック送信済み
|
||||
like: いいね
|
||||
likeTooltip: この回答にいいね
|
||||
dislike: よくないね
|
||||
dislikeTooltip: この回答に不満
|
||||
feedbackTitle: 何が問題でしたか?
|
||||
feedbackDescription: 回答の問題点を教えてください
|
||||
feedbackPlaceholder: コメントを追加(任意)
|
||||
schedule:
|
||||
title: 自動実行しますか?
|
||||
dailyAt: 毎日 $1
|
||||
everyHour: 毎時間
|
||||
scheduleButton: タスクを予約
|
||||
maybeLater: 後で
|
||||
connectApp:
|
||||
connecting: 接続中...
|
||||
connectButton: $1 に接続
|
||||
authorizeTitle: 開いたタブで $1 を承認
|
||||
authorizeDescription: サインインを完了し、下のボタンをクリックしてください。
|
||||
authorizedButton: $1 の承認完了、続ける
|
||||
skipButton: スキップして手動で実行
|
||||
suggested: $1 を推奨
|
||||
connected: $1 に接続済み
|
||||
continuedWithout: $1 なしで続行
|
||||
connectForBetter: より良い結果のため $1 に接続
|
||||
doItManually: 手動で実行
|
||||
connectedSuccess: $1 の接続に成功
|
||||
connectFailed: $1 の接続に失敗
|
||||
footer:
|
||||
attachTabs: タブを添付(@)
|
||||
selectWorkspace: ワークスペースフォルダを選択
|
||||
connectApps: アプリを接続
|
||||
selectedText:
|
||||
removeTitle: 選択テキストを削除
|
||||
attachedTabs:
|
||||
removeTab: タブを削除
|
||||
newtab:
|
||||
search:
|
||||
placeholder: BrowserOSに質問、または$1を検索...
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
selectedTab: タブ
|
||||
appsNew: 新着!
|
||||
appsTooltip: 直接接続したアプリはより正確・高速な回答を提供します!
|
||||
addWorkspace: ワークスペースを追加
|
||||
tip:
|
||||
label: ヒント:
|
||||
nextTip: 次のヒント
|
||||
dismiss: 閉じる
|
||||
signIn:
|
||||
title: データを同期
|
||||
description: 会話履歴をクラウドに同期するにはサインインしてください。
|
||||
dontAskAgain: 今後表示しない
|
||||
signInButton: サインイン
|
||||
importData:
|
||||
title: データをインポート
|
||||
description: Chromeからブックマーク、履歴、パスワードを取り込みます。
|
||||
dontShowAgain: 今後表示しない
|
||||
openSettings: インポート設定を開く
|
||||
shortcuts:
|
||||
title: キーボードショートカット
|
||||
description: BrowserOSをより速く操作するためのショートカット
|
||||
moreComingSoon: さらにショートカットを追加予定
|
||||
sidebar:
|
||||
nav:
|
||||
home: ホーム
|
||||
connectApps: アプリ接続
|
||||
scheduledTasks: 予定タスク
|
||||
workflows: ワークフロー
|
||||
skills: スキル
|
||||
memory: メモリ
|
||||
soul: ソウル
|
||||
settings: 設定
|
||||
footer:
|
||||
about: BrowserOSについて
|
||||
shortcuts: ショートカット
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
personal: 個人
|
||||
updateProfile: プロフィール更新
|
||||
signOut: サインアウト
|
||||
signIn: サインイン
|
||||
common:
|
||||
cancel: キャンセル
|
||||
submit: 送信
|
||||
save: 保存
|
||||
delete: 削除
|
||||
edit: 編集
|
||||
close: 閉じる
|
||||
loading: 読み込み中...
|
||||
error: エラーが発生しました
|
||||
apps: アプリ
|
||||
tabs: タブ
|
||||
@@ -1,134 +0,0 @@
|
||||
extName: BrowserOS Assistant
|
||||
extActionTitle: BrowserOS에게 물어보기
|
||||
chat:
|
||||
input:
|
||||
placeholderAgent: 무엇을 할까요?
|
||||
placeholderChat: 이 페이지에 대해 질문...
|
||||
placeholderTranscribing: 전사 중...
|
||||
send: 전송
|
||||
stop: 중지
|
||||
stopRecording: 녹음 중지
|
||||
transcribing: 전사 중
|
||||
voiceInput: 음성 입력
|
||||
mode:
|
||||
agent: 에이전트 모드 ON
|
||||
chat: 채팅 모드 ON
|
||||
agentTooltip: AI가 탐색, 클릭, 이동 가능
|
||||
chatTooltip: AI는 읽기만 가능, 클릭/이동 불가
|
||||
empty:
|
||||
agentTitle: 에이전트가 대기 중
|
||||
agentSubtitle: AI가 작업을 자동화하고 탐색합니다
|
||||
chatTitle: 이 페이지와 채팅
|
||||
chatSubtitle: 현재 페이지나 주제에 대해 질문하세요
|
||||
header:
|
||||
changeProvider: AI 제공자 변경
|
||||
newConversation: 새 대화
|
||||
chatHistory: 대화 기록
|
||||
starOnGithub: Github에서 Star
|
||||
settings: 설정
|
||||
error:
|
||||
dailyLimitTitle: 일일 한도 도달
|
||||
connectionFailedTitle: 연결 실패
|
||||
genericTitle: 문제가 발생했습니다
|
||||
connectionMessage: BrowserOS 에이전트에 연결할 수 없습니다. 아래 안내를 따르세요.
|
||||
rateLimitMessage: 무제한 사용을 위해 API 키를 추가하세요.
|
||||
troubleshootingLink: 문제 해결 가이드 보기
|
||||
kimiApiKeyLink: Kimi API 키 얻는 방법 알아보기
|
||||
getApiKey: API 키 받기
|
||||
tryAgain: 다시 시도
|
||||
actions:
|
||||
copy: 복사
|
||||
copyToClipboard: 클립보드에 복사
|
||||
feedbackSubmitted: 피드백 전송됨
|
||||
like: 좋아요
|
||||
likeTooltip: 이 응답이 좋아요
|
||||
dislike: 싫어요
|
||||
dislikeTooltip: 이 응답이 싫어요
|
||||
feedbackTitle: 무엇이 잘못되었나요?
|
||||
feedbackDescription: 이 응답의 문제를 공유하여 개선을 도와주세요.
|
||||
feedbackPlaceholder: 댓글 추가 (선택사항)
|
||||
schedule:
|
||||
title: 자동으로 실행할까요?
|
||||
dailyAt: 매일 $1
|
||||
everyHour: 매시간
|
||||
scheduleButton: 작업 예약
|
||||
maybeLater: 나중에
|
||||
connectApp:
|
||||
connecting: 연결 중...
|
||||
connectButton: $1 연결
|
||||
authorizeTitle: 열린 탭에서 $1 인증
|
||||
authorizeDescription: 로그인을 완료한 후 아래 버튼을 클릭하세요.
|
||||
authorizedButton: $1 인증 완료, 계속하기
|
||||
skipButton: 건너뛰기, 수동으로 실행
|
||||
suggested: $1 추천
|
||||
connected: $1 연결됨
|
||||
continuedWithout: $1 없이 계속
|
||||
connectForBetter: 더 나은 결과를 위해 $1 연결
|
||||
doItManually: 수동으로 실행
|
||||
connectedSuccess: $1 연결 성공
|
||||
connectFailed: $1 연결 실패
|
||||
footer:
|
||||
attachTabs: 탭 첨부 (@)
|
||||
selectWorkspace: 작업 폴더 선택
|
||||
connectApps: 앱 연결
|
||||
selectedText:
|
||||
removeTitle: 선택한 텍스트 제거
|
||||
attachedTabs:
|
||||
removeTab: 탭 제거
|
||||
newtab:
|
||||
search:
|
||||
placeholder: BrowserOS에게 묻거나 $1 검색...
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
selectedTab: 탭
|
||||
appsNew: 신규!
|
||||
appsTooltip: 직접 연결된 앱은 더 정확하고 빠른 응답을 제공합니다!
|
||||
addWorkspace: 작업 공간 추가
|
||||
tip:
|
||||
label: "팁:"
|
||||
nextTip: 다음 팁
|
||||
dismiss: 닫기
|
||||
signIn:
|
||||
title: 데이터 동기화
|
||||
description: 대화 기록을 클라우드에 동기화하려면 로그인하세요.
|
||||
dontAskAgain: 다시 묻지 않기
|
||||
signInButton: 로그인
|
||||
importData:
|
||||
title: 데이터 가져오기
|
||||
description: Chrome에서 북마크, 기록, 비밀번호를 가져옵니다.
|
||||
dontShowAgain: 다시 표시하지 않기
|
||||
openSettings: 가져오기 설정 열기
|
||||
shortcuts:
|
||||
title: 키보드 단축키
|
||||
description: BrowserOS를 더 빠르게 탐색하려면 이 단축키를 사용하세요.
|
||||
moreComingSoon: 더 많은 단축키가 곧 제공됩니다
|
||||
sidebar:
|
||||
nav:
|
||||
home: 홈
|
||||
connectApps: 앱 연결
|
||||
scheduledTasks: 예약된 작업
|
||||
workflows: 워크플로우
|
||||
skills: 스킬
|
||||
memory: 메모리
|
||||
soul: 소울
|
||||
settings: 설정
|
||||
footer:
|
||||
about: BrowserOS 정보
|
||||
shortcuts: 단축키
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
personal: 개인
|
||||
updateProfile: 프로필 업데이트
|
||||
signOut: 로그아웃
|
||||
signIn: 로그인
|
||||
common:
|
||||
cancel: 취소
|
||||
submit: 제출
|
||||
save: 저장
|
||||
delete: 삭제
|
||||
edit: 편집
|
||||
close: 닫기
|
||||
loading: 로딩 중...
|
||||
error: 문제가 발생했습니다
|
||||
apps: 앱
|
||||
tabs: 탭
|
||||
@@ -1,134 +0,0 @@
|
||||
extName: Assistente BrowserOS
|
||||
extActionTitle: Pergunte ao BrowserOS
|
||||
chat:
|
||||
input:
|
||||
placeholderAgent: O que devo fazer?
|
||||
placeholderChat: Pergunte sobre esta página...
|
||||
placeholderTranscribing: Transcrevendo...
|
||||
send: Enviar
|
||||
stop: Parar
|
||||
stopRecording: Parar gravação
|
||||
transcribing: Transcrevendo
|
||||
voiceInput: Entrada de voz
|
||||
mode:
|
||||
agent: Modo Agente ATIVO
|
||||
chat: Modo Chat ATIVO
|
||||
agentTooltip: A IA pode navegar, clicar e acessar sites
|
||||
chatTooltip: A IA só lê, não pode clicar ou navegar
|
||||
empty:
|
||||
agentTitle: Agente à sua disposição
|
||||
agentSubtitle: Deixe a IA automatizar tarefas e navegar por você
|
||||
chatTitle: Converse com esta página
|
||||
chatSubtitle: Faça perguntas sobre a página atual ou qualquer tópico
|
||||
header:
|
||||
changeProvider: Mudar provedor de IA
|
||||
newConversation: Nova conversa
|
||||
chatHistory: Histórico de chat
|
||||
starOnGithub: Estrelar no GitHub
|
||||
settings: Configurações
|
||||
error:
|
||||
dailyLimitTitle: Limite diário atingido
|
||||
connectionFailedTitle: Falha na conexão
|
||||
genericTitle: Algo deu errado
|
||||
connectionMessage: Não foi possível conectar ao agente BrowserOS. Siga as instruções abaixo.
|
||||
rateLimitMessage: Adicione sua própria chave de API para uso ilimitado.
|
||||
troubleshootingLink: Ver guia de solução de problemas
|
||||
kimiApiKeyLink: Saiba como obter uma chave de API Kimi
|
||||
getApiKey: obtenha sua chave de API
|
||||
tryAgain: Tentar novamente
|
||||
actions:
|
||||
copy: Copiar
|
||||
copyToClipboard: Copiar para a área de transferência
|
||||
feedbackSubmitted: Feedback enviado
|
||||
like: Curtir
|
||||
likeTooltip: Curtir esta resposta
|
||||
dislike: Descurtir
|
||||
dislikeTooltip: Não curtir esta resposta
|
||||
feedbackTitle: O que deu errado?
|
||||
feedbackDescription: Ajude-nos a melhorar informando o que estava errado.
|
||||
feedbackPlaceholder: Adicione um comentário (opcional)
|
||||
schedule:
|
||||
title: Executar isso automaticamente?
|
||||
dailyAt: diariamente às $1
|
||||
everyHour: a cada hora
|
||||
scheduleButton: Agendar esta tarefa
|
||||
maybeLater: Talvez depois
|
||||
connectApp:
|
||||
connecting: Conectando...
|
||||
connectButton: Conectar $1
|
||||
authorizeTitle: Autorize $1 na aba aberta
|
||||
authorizeDescription: Complete o fluxo de login, depois clique no botão abaixo.
|
||||
authorizedButton: Autorizei $1, continuar
|
||||
skipButton: Pular, fazer manualmente
|
||||
suggested: $1 sugerido
|
||||
connected: $1 conectado
|
||||
continuedWithout: Continuando sem $1
|
||||
connectForBetter: Conecte $1 para melhores resultados
|
||||
doItManually: Fazer manualmente
|
||||
connectedSuccess: $1 conectado com sucesso
|
||||
connectFailed: Falha ao conectar $1
|
||||
footer:
|
||||
attachTabs: Anexar abas (@)
|
||||
selectWorkspace: Selecionar workspace
|
||||
connectApps: Conectar apps
|
||||
selectedText:
|
||||
removeTitle: Remover texto selecionado
|
||||
attachedTabs:
|
||||
removeTab: Remover aba
|
||||
newtab:
|
||||
search:
|
||||
placeholder: Pergunte ao BrowserOS ou pesquise em $1...
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
selectedTab: Aba
|
||||
appsNew: Novo!
|
||||
appsTooltip: Apps conectados diretamente oferecem respostas mais precisas e rápidas!
|
||||
addWorkspace: Adicionar workspace
|
||||
tip:
|
||||
label: "Dica:"
|
||||
nextTip: Próxima dica
|
||||
dismiss: Fechar
|
||||
signIn:
|
||||
title: Sincronize seus dados
|
||||
description: Entre para sincronizar o histórico de conversas na nuvem.
|
||||
dontAskAgain: Não perguntar novamente
|
||||
signInButton: Entrar
|
||||
importData:
|
||||
title: Importe seus dados
|
||||
description: Traga favoritos, histórico e senhas do Chrome.
|
||||
dontShowAgain: Não mostrar novamente
|
||||
openSettings: Abrir Configurações de Importação
|
||||
shortcuts:
|
||||
title: Atalhos de Teclado
|
||||
description: Use atalhos para navegar mais rápido no BrowserOS
|
||||
moreComingSoon: Mais atalhos em breve
|
||||
sidebar:
|
||||
nav:
|
||||
home: Início
|
||||
connectApps: Conectar Apps
|
||||
scheduledTasks: Tarefas Agendadas
|
||||
workflows: Fluxos de trabalho
|
||||
skills: Habilidades
|
||||
memory: Memória
|
||||
soul: Alma
|
||||
settings: Configurações
|
||||
footer:
|
||||
about: Sobre o BrowserOS
|
||||
shortcuts: Atalhos
|
||||
branding:
|
||||
alt: BrowserOS
|
||||
personal: Pessoal
|
||||
updateProfile: Atualizar Perfil
|
||||
signOut: Sair
|
||||
signIn: Entrar
|
||||
common:
|
||||
cancel: Cancelar
|
||||
submit: Enviar
|
||||
save: Salvar
|
||||
delete: Excluir
|
||||
edit: Editar
|
||||
close: Fechar
|
||||
loading: Carregando...
|
||||
error: Algo deu errado
|
||||
apps: Apps
|
||||
tabs: Abas
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user