mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
43 Commits
feat/eval
...
feat/inter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc84af8d14 | ||
|
|
d01eac9d0b | ||
|
|
be2422437c | ||
|
|
b2d3acde4e | ||
|
|
9257832acf | ||
|
|
7bde0d59fa | ||
|
|
1c737b0f02 | ||
|
|
5d0a2b9bfe | ||
|
|
720baaed3e | ||
|
|
cee9c764b1 | ||
|
|
7bdeeb85d5 | ||
|
|
19069cb9c4 | ||
|
|
5bb6143373 | ||
|
|
f4d4b73a24 | ||
|
|
d965698905 | ||
|
|
50b2f45590 | ||
|
|
1b88ade021 | ||
|
|
079a254fa4 | ||
|
|
42aa0ff1ef | ||
|
|
4000f094f6 | ||
|
|
151be81cee | ||
|
|
46a8326140 | ||
|
|
4b18723a21 | ||
|
|
4909927c03 | ||
|
|
22c5e85707 | ||
|
|
59b00a6837 | ||
|
|
44af9aea6d | ||
|
|
1779e1e7bd | ||
|
|
2597cdbc70 | ||
|
|
515ad44826 | ||
|
|
2a6848bc1d | ||
|
|
74f6a2dff1 | ||
|
|
58adac17db | ||
|
|
e67c17a0f8 | ||
|
|
94e3f99adb | ||
|
|
e2069bc999 | ||
|
|
2d51c82722 | ||
|
|
29056226bb | ||
|
|
d1d2074abc | ||
|
|
41c9b1547c | ||
|
|
8b0e6dbfd3 | ||
|
|
07a2d13f16 | ||
|
|
46031ed573 |
@@ -9,6 +9,9 @@ on:
|
||||
jobs:
|
||||
security-audit:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
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,47 +13,46 @@ permissions:
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
CLAAssistant:
|
||||
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'
|
||||
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'
|
||||
- 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 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)
|
||||
path-to-signatures: 'cla-signatures.json'
|
||||
path-to-document: 'https://github.com/${{ github.repository }}/blob/main/CLA.md'
|
||||
branch: 'main'
|
||||
|
||||
# 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
|
||||
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
|
||||
custom-notsigned-prcomment: |
|
||||
**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.
|
||||
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).
|
||||
|
||||
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
|
||||
**To sign the CLA**, please add a comment to this PR with the following text:
|
||||
|
||||
**To sign the CLA, please comment on this PR with:**
|
||||
`I have read the CLA Document and I hereby sign the CLA`
|
||||
```
|
||||
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: |
|
||||
**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'
|
||||
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 # Can push branches and create commits
|
||||
pull-requests: write # Can create and update PRs
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -38,11 +38,5 @@ 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,11 +4,16 @@ 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:
|
||||
@@ -28,6 +33,9 @@ jobs:
|
||||
typecheck:
|
||||
name: runner / Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/browseros-agent
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -42,6 +50,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
|
||||
- name: Prepare wxt
|
||||
run: VITE_PUBLIC_BROWSEROS_API=http://localhost:3000 bun run --cwd apps/agent wxt prepare
|
||||
|
||||
- name: Run codegen
|
||||
run: bun run --cwd apps/agent codegen
|
||||
|
||||
@@ -5,9 +5,9 @@ on:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
pull-requests: write # Read PR details and add labels
|
||||
issues: write # Labels are managed via issues API
|
||||
contents: read # Read repository content
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-pr-title:
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/agent-sdk
|
||||
working-directory: packages/browseros-agent/packages/agent-sdk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun ci
|
||||
working-directory: .
|
||||
working-directory: packages/browseros-agent
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -7,18 +7,21 @@ 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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ gclient.json
|
||||
**/resources/binaries/
|
||||
|
||||
packages/browseros/build/tools/
|
||||
|
||||
# AI SDK DevTools traces
|
||||
.devtools/
|
||||
|
||||
3840
.vscode/PythonImportHelper-v2-Completion.json
vendored
3840
.vscode/PythonImportHelper-v2-Completion.json
vendored
File diff suppressed because it is too large
Load Diff
57
lefthook.yml
Normal file
57
lefthook.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
commit-msg:
|
||||
commands:
|
||||
conventional:
|
||||
run: |
|
||||
msg=$(head -1 {1})
|
||||
if [[ ! "$msg" =~ ^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?\!?:\ .+ ]]; then
|
||||
echo "Commit message must follow Conventional Commits format:"
|
||||
echo " <type>(<optional scope>): <description>"
|
||||
echo " Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " feat(auth): add OAuth2 support"
|
||||
echo " fix: resolve null pointer exception"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pre-commit:
|
||||
commands:
|
||||
biome-check:
|
||||
root: "packages/browseros-agent/"
|
||||
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
|
||||
run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
|
||||
stage_fixed: true
|
||||
|
||||
file-length:
|
||||
root: "packages/browseros-agent/"
|
||||
glob: "*.{ts,tsx}"
|
||||
exclude: "*.{test,spec,d}.ts|*.{test,spec}.tsx|**/__tests__/**|**/tests/**|**/*.generated.*"
|
||||
run: |
|
||||
for file in {staged_files}; do
|
||||
if [[ -f "$file" ]]; then
|
||||
lines=$(wc -l < "$file" | tr -d ' ')
|
||||
if [[ $lines -gt 400 ]]; then
|
||||
echo "⚠️ Warning: $file has $lines lines (threshold: 400)"
|
||||
echo " Consider splitting this file if it has multiple responsibilities."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
pre-push:
|
||||
commands:
|
||||
branch-name:
|
||||
run: |
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [[ "$branch" == "main" || "$branch" == "master" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ ! "$branch" =~ ^(feat|fix|bugfix|hotfix|release|docs|refactor|test|chore|experiment)/[a-z0-9-]+$ ]]; then
|
||||
echo "⚠️ Warning: Branch name '$branch' doesn't match recommended format."
|
||||
echo " Use: <type>/<short-description>"
|
||||
echo " Types: feat, fix, bugfix, hotfix, release, docs, refactor, test, chore, experiment"
|
||||
echo " Example: feat/add-auth, fix/login-crash"
|
||||
echo ""
|
||||
echo " To rename your branch:"
|
||||
echo " git branch -m <new-name>"
|
||||
echo " git push -u origin <new-name>"
|
||||
fi
|
||||
286
packages/browseros-agent/.claude/skills/test-ui/SKILL.md
Normal file
286
packages/browseros-agent/.claude/skills/test-ui/SKILL.md
Normal file
@@ -0,0 +1,286 @@
|
||||
---
|
||||
name: test-ui
|
||||
description: Test the BrowserOS agent extension UI by starting the dev environment and visually verifying changes via CDP. Covers the new tab page (left sidebar — Home, Scheduled Tasks, Settings, etc.) and the right side panel (chat interface). Use after making UI changes to apps/agent/.
|
||||
argument-hint: [what to test, e.g. "verify the new settings page renders correctly"]
|
||||
---
|
||||
|
||||
# Test Agent UI
|
||||
|
||||
Visually test the BrowserOS agent extension UI — both the new tab page (left sidebar) and the right side panel (chat) — by starting the dev environment and inspecting via CDP.
|
||||
|
||||
## When to use
|
||||
|
||||
After making code changes to `apps/agent/` (the Chrome extension), use this skill to:
|
||||
- Verify new UI components render correctly
|
||||
- Check navigation between views works
|
||||
- Confirm layout/styling changes look right
|
||||
- Test interactive elements (buttons, inputs, forms)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go** must be installed (`brew install go`) — the dev tool is written in Go
|
||||
- **BrowserOS.app** must be installed at `/Applications/BrowserOS.app/`
|
||||
- The `scripts/dev/inspect-ui.ts` utility must exist (CDP inspector script)
|
||||
|
||||
## Step 1: Start the dev environment
|
||||
|
||||
```bash
|
||||
bun run dev:watch -- --new
|
||||
```
|
||||
|
||||
This single command handles everything:
|
||||
- Builds the Go dev CLI tool
|
||||
- Picks random available ports (avoids conflicts)
|
||||
- Creates a fresh browser profile
|
||||
- Builds controller-ext
|
||||
- Runs GraphQL codegen if `apps/agent/generated/graphql/` doesn't exist
|
||||
- Starts the agent extension with WXT HMR (hot module replacement)
|
||||
- Waits for CDP to be ready
|
||||
- Starts the MCP server
|
||||
|
||||
Run it in the background and **read the output to find the CDP port**:
|
||||
|
||||
```
|
||||
[info] Ports: CDP=9552 Server=9065 Extension=9929
|
||||
```
|
||||
|
||||
The CDP port is randomized. You MUST extract it from the output and set it for all subsequent commands:
|
||||
|
||||
```bash
|
||||
export BROWSEROS_CDP_PORT=<port from output>
|
||||
```
|
||||
|
||||
Wait for these messages before proceeding:
|
||||
1. `[server] CDP ready`
|
||||
2. `[server] HTTP server listening`
|
||||
|
||||
## Step 2: Discover targets
|
||||
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts targets
|
||||
```
|
||||
|
||||
You will see targets like:
|
||||
- `[service_worker]` — extension background scripts (not directly testable for UI)
|
||||
- `[page] chrome-extension://bflpfmnmnokmjhmgnolecpppdbdophmk/app.html#/...` — **New tab page (left sidebar)**
|
||||
- `[page] sidepanel.html` — **Right side panel (chat)**
|
||||
|
||||
The two main testable surfaces:
|
||||
- **`app.html`** — the new tab page with left sidebar (Home, Connect Apps, Scheduled Tasks, Skills, Memory, Soul, Settings)
|
||||
- **`sidepanel.html`** — the right side panel chat interface
|
||||
|
||||
## Step 3: Navigate to the main UI
|
||||
|
||||
A fresh profile opens the **onboarding page** (`app.html#/onboarding`). Navigate to the home page first:
|
||||
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts eval app.html "window.location.hash = '#/home'"
|
||||
```
|
||||
|
||||
Verify with a snapshot (not screenshot — snapshot is faster and sufficient for structural checks):
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts snapshot app.html
|
||||
```
|
||||
|
||||
## Snapshot vs Screenshot
|
||||
|
||||
**Prefer `snapshot` for most checks** — it's fast, text-based, and tells you what elements exist, their text, and their IDs. Use it after every navigation or interaction to verify state.
|
||||
|
||||
**Use `screenshot` only when you need visual verification** — layout changes, CSS/styling, colors, images, or a final "does it look right" check. Screenshots are expensive (capture → save → read image).
|
||||
|
||||
| Check | Use |
|
||||
|-------|-----|
|
||||
| Did the page navigate? | `snapshot` — look for new elements |
|
||||
| Does my new component render? | `snapshot` — look for its text/role |
|
||||
| Did a click change state? | `snapshot` — check element names/values |
|
||||
| Is the layout correct? | `screenshot` — visual check needed |
|
||||
| Do CSS changes look right? | `screenshot` — visual check needed |
|
||||
| Final verification before committing | `screenshot` — one visual confirmation |
|
||||
|
||||
## Step 4: Test the new tab page (left sidebar)
|
||||
|
||||
### Get element IDs
|
||||
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts snapshot app.html
|
||||
```
|
||||
|
||||
Output shows interactive elements with IDs:
|
||||
```
|
||||
[52] link "Home"
|
||||
[57] link "Connect Apps"
|
||||
[65] link "Scheduled Tasks"
|
||||
[74] link "Skills"
|
||||
[103] link "Settings"
|
||||
```
|
||||
|
||||
### Navigate via click or hash routing
|
||||
|
||||
**Click-based** (use element IDs from snapshot):
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts click app.html 65 # Click "Scheduled Tasks"
|
||||
```
|
||||
|
||||
**Hash routing** (faster, no snapshot needed):
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts eval app.html "window.location.hash = '#/settings'"
|
||||
bun scripts/dev/inspect-ui.ts eval app.html "window.location.hash = '#/scheduled-tasks'"
|
||||
bun scripts/dev/inspect-ui.ts eval app.html "window.location.hash = '#/home'"
|
||||
```
|
||||
|
||||
### Verify navigation
|
||||
|
||||
```bash
|
||||
# Snapshot to confirm the page changed (fast, preferred)
|
||||
bun scripts/dev/inspect-ui.ts snapshot app.html
|
||||
|
||||
# Screenshot only if you need to check visual layout
|
||||
bun scripts/dev/inspect-ui.ts screenshot app.html /tmp/settings.png
|
||||
```
|
||||
|
||||
### CRITICAL: Re-snapshot after every navigation
|
||||
|
||||
React re-renders change element IDs. **Always run snapshot again** before clicking/filling after navigating to a new view. Using stale IDs will fail.
|
||||
|
||||
## Step 5: Open and test the right side panel
|
||||
|
||||
The side panel starts **disabled** in a fresh profile. Open it using BrowserOS-specific APIs:
|
||||
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts open-sidepanel
|
||||
```
|
||||
|
||||
Wait 2 seconds for it to appear as a target, then:
|
||||
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts screenshot sidepanel /tmp/panel.png
|
||||
bun scripts/dev/inspect-ui.ts snapshot sidepanel
|
||||
```
|
||||
|
||||
### Interact with the side panel
|
||||
|
||||
```bash
|
||||
# Get element IDs
|
||||
bun scripts/dev/inspect-ui.ts snapshot sidepanel
|
||||
# Output: [37] textbox "What should I do?"
|
||||
# [124] button "Send"
|
||||
# [60] link "Chat history"
|
||||
# [99] button "Agent Mode ON"
|
||||
|
||||
# Fill the chat input and press Enter to send
|
||||
bun scripts/dev/inspect-ui.ts fill sidepanel 37 "Hello world"
|
||||
bun scripts/dev/inspect-ui.ts press_key sidepanel Enter
|
||||
|
||||
# Or click the Send button
|
||||
bun scripts/dev/inspect-ui.ts click sidepanel 124
|
||||
|
||||
# Wait for a response to appear
|
||||
bun scripts/dev/inspect-ui.ts wait_for sidepanel text "response text"
|
||||
|
||||
# Scroll down to see more content
|
||||
bun scripts/dev/inspect-ui.ts scroll sidepanel down 3
|
||||
|
||||
# Hover over an element to test hover states
|
||||
bun scripts/dev/inspect-ui.ts hover sidepanel 99
|
||||
|
||||
# Snapshot to verify state changed (fast, preferred)
|
||||
bun scripts/dev/inspect-ui.ts snapshot sidepanel
|
||||
|
||||
# Screenshot only for visual/layout verification
|
||||
bun scripts/dev/inspect-ui.ts screenshot sidepanel /tmp/result.png
|
||||
```
|
||||
|
||||
## Step 6: Verify and iterate
|
||||
|
||||
### The core loop
|
||||
|
||||
```
|
||||
snapshot → identify element IDs → click/fill/press_key → snapshot → verify
|
||||
```
|
||||
|
||||
Use `screenshot` only when visual layout verification is needed (CSS changes, final check).
|
||||
|
||||
### After making code changes
|
||||
|
||||
1. Fix the code in `apps/agent/`
|
||||
2. WXT HMR will hot-reload the extension automatically (watch mode)
|
||||
3. Wait 2-3 seconds for the reload to complete
|
||||
4. **Re-snapshot** — element IDs WILL change after HMR reload
|
||||
5. Verify the fix with snapshot (or screenshot if visual)
|
||||
|
||||
### Check server logs
|
||||
|
||||
The dev server output (running in background) contains useful diagnostics:
|
||||
- `[agent]` — WXT build/HMR status, compilation errors
|
||||
- `[server]` — MCP server logs, tool execution, errors
|
||||
- `[build]` — Extension build output
|
||||
|
||||
If the UI isn't rendering, check for build errors in the `[agent]` output.
|
||||
|
||||
### Check for JavaScript errors
|
||||
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts eval sidepanel "JSON.stringify(window.__errors || 'no errors')"
|
||||
```
|
||||
|
||||
Or check the console for React errors:
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts eval app.html "document.querySelector('#root')?.innerHTML?.substring(0, 200)"
|
||||
```
|
||||
|
||||
### Verify API connectivity
|
||||
|
||||
The extension talks to the MCP server. Verify the server is reachable:
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts eval sidepanel "fetch('http://127.0.0.1:<serverPort>/health').then(r => r.ok).catch(() => false)"
|
||||
```
|
||||
|
||||
### Common issues
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Blank page after navigation | React render error | Check `eval` for JS errors |
|
||||
| Element IDs don't match | Page re-rendered (HMR/navigation) | Re-run `snapshot` before interacting |
|
||||
| `open-sidepanel` fails | Extension not fully loaded | Wait longer after dev server starts |
|
||||
| Click does nothing | Element not visible (below fold) | Use `scroll` first, then re-snapshot |
|
||||
| `wait_for` times out | Content hasn't loaded yet | Check server logs for API errors |
|
||||
|
||||
## Available commands reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `targets` | List all CDP targets, marks extension pages with `[EXTENSION]` |
|
||||
| `screenshot <target> [file]` | Capture PNG screenshot (default: `screenshot.png`) |
|
||||
| `snapshot <target>` | Print accessibility tree with `[elementId] role "name"` |
|
||||
| `click <target> <elementId>` | Click element by ID (3-tier coordinate fallback + JS click) |
|
||||
| `fill <target> <elementId> <text>` | Focus element, clear, type text |
|
||||
| `press_key <target> <key>` | Press key or combo: `Enter`, `Escape`, `Tab`, `Control+A`, `Meta+Shift+P` |
|
||||
| `scroll <target> <dir> [amount]` | Scroll `up`/`down`/`left`/`right`, amount in ticks (default 3) |
|
||||
| `hover <target> <elementId>` | Hover over element (for tooltips, hover states) |
|
||||
| `select_option <target> <id> <val>` | Select dropdown option by value or visible text |
|
||||
| `wait_for <target> text\|selector <v>` | Wait up to 10s for text or CSS selector to appear |
|
||||
| `eval <target> <expression>` | Run JavaScript in the target's context |
|
||||
| `open-sidepanel` | Enable and open the right side panel |
|
||||
|
||||
`<target>` is a URL substring (e.g., `sidepanel`, `app.html`) or numeric index from `targets` output.
|
||||
|
||||
## Known app.html routes
|
||||
|
||||
These can be used with `eval app.html "window.location.hash = '#/<route>'"`:
|
||||
|
||||
| Route | View |
|
||||
|-------|------|
|
||||
| `/home` | Home page with search bar and top sites |
|
||||
| `/settings` | Settings (LLM providers, customization, workflows, MCP) |
|
||||
| `/scheduled-tasks` | Scheduled Tasks management |
|
||||
| `/onboarding` | Onboarding flow (first-run experience) |
|
||||
|
||||
## Gotchas learned from real testing
|
||||
|
||||
1. **Ports are randomized** with `--new` — always extract from dev server output
|
||||
2. **Fresh profile = onboarding page** — navigate to `#/home` to see the main UI
|
||||
3. **Element IDs change after navigation** — always re-snapshot before clicking
|
||||
4. **Side panel starts disabled** — `open-sidepanel` handles the BrowserOS-specific enable + toggle API
|
||||
5. **`Input.enable` does not exist** — the CDP Input domain has no enable method (already handled in the script)
|
||||
6. **`DOM.getDocument` required** — must be called before DOM operations like `pushNodesByBackendIdsToFrontend` (already handled in the script)
|
||||
7. **Settings sub-navigation** — the settings page has its own left sidebar (BrowserOS AI, Chat & Council Provider, Search Provider, Customize BrowserOS, BrowserOS as MCP, Workflows) — use snapshot + click to navigate within settings
|
||||
41
packages/browseros-agent/.github/dependabot.yml
vendored
41
packages/browseros-agent/.github/dependabot.yml
vendored
@@ -1,41 +0,0 @@
|
||||
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:
|
||||
- '*'
|
||||
@@ -1,58 +0,0 @@
|
||||
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!
|
||||
@@ -165,3 +165,68 @@ Tests are in `apps/server/tests/`:
|
||||
- `agent/` - Agent tests (compaction, rate limiter)
|
||||
- `sdk/` - Agent SDK tests
|
||||
- `__helpers__/` - Test utilities and fixtures
|
||||
|
||||
## Self-Testing UI Changes
|
||||
|
||||
After making UI changes to the agent extension (`apps/agent/`), you can visually verify them using the CDP inspector script. This connects directly to the browser via Chrome DevTools Protocol and can inspect extension pages (side panel, new tab, etc.) that the agent's own tools cannot see.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The dev server must be running:
|
||||
```bash
|
||||
bun run dev:watch -- --new
|
||||
```
|
||||
Read the output to find the randomized CDP port, then:
|
||||
```bash
|
||||
export BROWSEROS_CDP_PORT=<port from output>
|
||||
```
|
||||
|
||||
### Workflow
|
||||
|
||||
1. **List all targets** to see what's available:
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts targets
|
||||
```
|
||||
|
||||
2. **Open the side panel** if it's not already open:
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts open-sidepanel
|
||||
```
|
||||
|
||||
3. **Take a screenshot** of the side panel:
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts screenshot sidepanel /tmp/panel.png
|
||||
```
|
||||
Then read `/tmp/panel.png` to view the result.
|
||||
|
||||
4. **Get the accessibility tree** for structural verification:
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts snapshot sidepanel
|
||||
```
|
||||
|
||||
5. **Click an element** by its ID from the snapshot:
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts click sidepanel 142
|
||||
```
|
||||
|
||||
6. **Fill a text input** by its ID from the snapshot:
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts fill sidepanel 85 "search query"
|
||||
```
|
||||
|
||||
7. **Evaluate JavaScript** in the extension context:
|
||||
```bash
|
||||
bun scripts/dev/inspect-ui.ts eval sidepanel "document.title"
|
||||
```
|
||||
|
||||
### Interaction workflow
|
||||
|
||||
The typical loop is: snapshot → identify element IDs → click/fill → screenshot to verify.
|
||||
Element IDs come from the `[number]` in snapshot output (these are `backendDOMNodeId` values).
|
||||
This uses the same element resolution as the server's MCP tools — no coordinate guessing.
|
||||
|
||||
### Target selection
|
||||
|
||||
The `<target>` argument can be:
|
||||
- An **index** from the `targets` output (e.g., `3`)
|
||||
- A **URL substring** (e.g., `sidepanel`, `newtab`, `chrome-extension://`)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"vcs": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -68,7 +69,11 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<img src={ProductLogo} alt="BrowserOS" className="size-8" />
|
||||
<img
|
||||
src={ProductLogo}
|
||||
alt={i18n.t('sidebar.branding.alt')}
|
||||
className="size-8"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -105,7 +110,9 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
|
||||
: 'font-medium text-primary',
|
||||
)}
|
||||
>
|
||||
{isLoggedIn ? 'Personal' : 'Sign in'}
|
||||
{isLoggedIn
|
||||
? i18n.t('sidebar.branding.personal')
|
||||
: i18n.t('sidebar.branding.signIn')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -123,14 +130,14 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
|
||||
{displayName}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
Personal
|
||||
{i18n.t('sidebar.branding.personal')}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => navigate('/profile')}>
|
||||
<User className="mr-2 size-4" />
|
||||
Update Profile
|
||||
{i18n.t('sidebar.branding.updateProfile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@@ -138,13 +145,13 @@ export const SidebarBranding: FC<SidebarBrandingProps> = ({
|
||||
variant="destructive"
|
||||
>
|
||||
<LogOut className="mr-2 size-4" />
|
||||
Sign out
|
||||
{i18n.t('sidebar.branding.signOut')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => navigate('/login')}>
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Sign in
|
||||
{i18n.t('sidebar.branding.signIn')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
import { i18n } from '#i18n'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -24,40 +25,44 @@ interface SidebarNavigationProps {
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
name: string
|
||||
nameKey: string
|
||||
to: string
|
||||
icon: typeof Home
|
||||
feature?: Feature
|
||||
}
|
||||
|
||||
const primaryNavItems: NavItem[] = [
|
||||
{ name: 'Home', to: '/home', icon: Home },
|
||||
{ nameKey: 'sidebar.nav.home', to: '/home', icon: Home },
|
||||
{
|
||||
name: 'Connect Apps',
|
||||
nameKey: 'sidebar.nav.connectApps',
|
||||
to: '/connect-apps',
|
||||
icon: PlugZap,
|
||||
feature: Feature.MANAGED_MCP_SUPPORT,
|
||||
},
|
||||
{ name: 'Scheduled Tasks', to: '/scheduled', icon: CalendarClock },
|
||||
{
|
||||
name: 'Skills',
|
||||
nameKey: 'sidebar.nav.scheduledTasks',
|
||||
to: '/scheduled',
|
||||
icon: CalendarClock,
|
||||
},
|
||||
{
|
||||
nameKey: 'sidebar.nav.skills',
|
||||
to: '/home/skills',
|
||||
icon: Wand2,
|
||||
feature: Feature.SKILLS_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Memory',
|
||||
nameKey: 'sidebar.nav.memory',
|
||||
to: '/home/memory',
|
||||
icon: Brain,
|
||||
feature: Feature.MEMORY_SUPPORT,
|
||||
},
|
||||
{
|
||||
name: 'Soul',
|
||||
nameKey: 'sidebar.nav.soul',
|
||||
to: '/home/soul',
|
||||
icon: Sparkles,
|
||||
feature: Feature.SOUL_SUPPORT,
|
||||
},
|
||||
{ name: 'Settings', to: '/settings/ai', icon: Settings },
|
||||
{ nameKey: 'sidebar.nav.settings', to: '/settings/ai', icon: Settings },
|
||||
]
|
||||
|
||||
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
@@ -97,7 +102,7 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
expanded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
{i18n.t(item.nameKey as never)}
|
||||
</span>
|
||||
</NavLink>
|
||||
)
|
||||
@@ -106,7 +111,9 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
return (
|
||||
<Tooltip key={item.to}>
|
||||
<TooltipTrigger asChild>{navItem}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.name}</TooltipContent>
|
||||
<TooltipContent side="right">
|
||||
{i18n.t(item.nameKey as never)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Info, Keyboard } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -50,7 +51,7 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
|
||||
expanded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
About BrowserOS
|
||||
{i18n.t('sidebar.footer.about')}
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
@@ -68,7 +69,7 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
|
||||
expanded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
Shortcuts
|
||||
{i18n.t('sidebar.footer.shortcuts')}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
@@ -81,7 +82,9 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{shortcutsButton}</TooltipTrigger>
|
||||
<TooltipContent side="right">Shortcuts</TooltipContent>
|
||||
<TooltipContent side="right">
|
||||
{i18n.t('sidebar.footer.shortcuts')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -90,7 +93,9 @@ export const SidebarUserFooter: FC<SidebarUserFooterProps> = ({
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{aboutLink}</TooltipTrigger>
|
||||
<TooltipContent side="right">About BrowserOS</TooltipContent>
|
||||
<TooltipContent side="right">
|
||||
{i18n.t('sidebar.footer.about')}
|
||||
</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, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertDescription, AlertTitle }
|
||||
|
||||
@@ -104,10 +104,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
}
|
||||
|
||||
@@ -251,10 +251,10 @@ function CarouselNext({
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
}
|
||||
|
||||
@@ -39,4 +39,4 @@ function CollapsibleContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
export { Collapsible, CollapsibleContent, CollapsibleTrigger }
|
||||
|
||||
@@ -198,11 +198,11 @@ function CommandShortcut({
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
}
|
||||
|
||||
@@ -283,18 +283,18 @@ function DropdownMenuSubContent({
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
}
|
||||
|
||||
@@ -179,12 +179,12 @@ function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useFormField,
|
||||
}
|
||||
|
||||
@@ -50,4 +50,4 @@ function HoverCardContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger }
|
||||
|
||||
@@ -184,7 +184,7 @@ export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
|
||||
@@ -55,4 +55,4 @@ function PopoverAnchor({
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }
|
||||
|
||||
@@ -49,4 +49,4 @@ function ResizableHandle({
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
|
||||
@@ -129,11 +129,11 @@ function SheetDescription({
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
closeButton
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
|
||||
@@ -86,4 +86,4 @@ function TabsContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }
|
||||
|
||||
@@ -68,4 +68,4 @@ function TooltipContent({
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FC } from 'react'
|
||||
import { HashRouter, Navigate, Route, Routes, useParams } from 'react-router'
|
||||
|
||||
import { NewTab } from '../newtab/index/NewTab'
|
||||
import { NewTabChat } from '../newtab/index/NewTabChat'
|
||||
import { NewTabLayout } from '../newtab/layout/NewTabLayout'
|
||||
import { Personalize } from '../newtab/personalize/Personalize'
|
||||
import { OnboardingDemo } from '../onboarding/demo/OnboardingDemo'
|
||||
@@ -79,6 +80,7 @@ export const App: FC = () => {
|
||||
{/* Home routes */}
|
||||
<Route path="home" element={<NewTabLayout />}>
|
||||
<Route index element={<NewTab />} />
|
||||
<Route path="chat" element={<NewTabChat />} />
|
||||
<Route path="personalize" element={<Personalize />} />
|
||||
<Route path="soul" element={<SoulPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type FC, useMemo, useState } from 'react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -13,14 +13,27 @@ 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 type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates'
|
||||
import {
|
||||
getProviderTemplate,
|
||||
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,
|
||||
@@ -101,12 +114,117 @@ 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,
|
||||
@@ -119,6 +237,68 @@ 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)
|
||||
@@ -130,6 +310,15 @@ 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,6 +61,8 @@ const providerTypeEnum = z.enum([
|
||||
'lmstudio',
|
||||
'bedrock',
|
||||
'browseros',
|
||||
'chatgpt-pro',
|
||||
'github-copilot',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -84,6 +86,9 @@ export const providerFormSchema = z
|
||||
secretAccessKey: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
sessionToken: z.string().optional(),
|
||||
// ChatGPT Pro (Codex)
|
||||
reasoningEffort: z.enum(['none', 'low', 'medium', 'high']).optional(),
|
||||
reasoningSummary: z.enum(['auto', 'concise', 'detailed']).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Azure: require either resourceName or baseUrl
|
||||
@@ -127,6 +132,10 @@ 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({
|
||||
@@ -182,6 +191,10 @@ 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') {
|
||||
@@ -209,6 +222,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
secretAccessKey: initialValues?.secretAccessKey || '',
|
||||
region: initialValues?.region || '',
|
||||
sessionToken: initialValues?.sessionToken || '',
|
||||
reasoningEffort: initialValues?.reasoningEffort || 'high',
|
||||
reasoningSummary: initialValues?.reasoningSummary || 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -301,6 +316,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
secretAccessKey: initialValues.secretAccessKey || '',
|
||||
region: initialValues.region || '',
|
||||
sessionToken: initialValues.sessionToken || '',
|
||||
reasoningEffort: initialValues.reasoningEffort || 'high',
|
||||
reasoningSummary: initialValues.reasoningSummary || 'auto',
|
||||
})
|
||||
setIsCustomModel(false)
|
||||
}
|
||||
@@ -326,6 +343,8 @@ export const NewProviderDialog: FC<NewProviderDialogProps> = ({
|
||||
secretAccessKey: '',
|
||||
region: '',
|
||||
sessionToken: '',
|
||||
reasoningEffort: 'high',
|
||||
reasoningSummary: 'auto',
|
||||
})
|
||||
setIsCustomModel(false)
|
||||
}
|
||||
@@ -363,6 +382,10 @@ 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
|
||||
}
|
||||
@@ -444,6 +467,84 @@ 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,8 +103,10 @@ export const ProviderCard: FC<ProviderCardProps> = ({
|
||||
for better performance.
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
) : provider.baseUrl ? (
|
||||
`${provider.modelId} • ${provider.baseUrl}`
|
||||
) : (
|
||||
provider.modelId
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,10 @@ 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,6 +23,8 @@ export interface ModelsData {
|
||||
bedrock: ModelInfo[]
|
||||
browseros: ModelInfo[]
|
||||
moonshot: ModelInfo[]
|
||||
'chatgpt-pro': ModelInfo[]
|
||||
'github-copilot': ModelInfo[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +92,42 @@ 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,6 +156,7 @@ export const ConnectMCP: FC = () => {
|
||||
})
|
||||
if (response.success) {
|
||||
removeServer(id)
|
||||
mutateUserIntegrations()
|
||||
} else {
|
||||
failedToRemoveMcp(name, 'Success not returned from server')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import useSWR from 'swr'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
interface UserMCPIntegrationsList {
|
||||
@@ -9,7 +9,11 @@ interface UserMCPIntegrationsList {
|
||||
count: number
|
||||
}
|
||||
|
||||
const getUserMCPIntegrations = async ([hostUrl]: [hostUrl: string]) => {
|
||||
export const INTEGRATIONS_QUERY_KEY = 'klavis-user-integrations'
|
||||
|
||||
const getUserMCPIntegrations = async (
|
||||
hostUrl: string,
|
||||
): Promise<UserMCPIntegrationsList> => {
|
||||
const response = await fetch(`${hostUrl}/klavis/user-integrations`)
|
||||
const data = (await response.json()) as UserMCPIntegrationsList
|
||||
return data
|
||||
@@ -18,12 +22,19 @@ const getUserMCPIntegrations = async ([hostUrl]: [hostUrl: string]) => {
|
||||
export const useGetUserMCPIntegrations = () => {
|
||||
const { baseUrl: agentServerUrl } = useAgentServerUrl()
|
||||
|
||||
return useSWR(
|
||||
agentServerUrl ? [agentServerUrl, 'klavis/user-integrations'] : null,
|
||||
getUserMCPIntegrations,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: true,
|
||||
},
|
||||
)
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +81,7 @@ 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) => {
|
||||
@@ -89,7 +90,7 @@ export const Chat: FC<Props> = ({
|
||||
})
|
||||
voice.clearTranscript()
|
||||
}
|
||||
}, [voice])
|
||||
}, [voice.transcript, voice.isTranscribing])
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -17,11 +17,8 @@ export const SettingsSidebarLayout: FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
track(SETTINGS_PAGE_VIEWED_EVENT, { page: location.pathname })
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
}, [])
|
||||
}, [location.pathname])
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
|
||||
@@ -7,8 +7,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import { ShortcutsDialog } from '@/entrypoints/newtab/index/ShortcutsDialog'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { SETTINGS_PAGE_VIEWED_EVENT } from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { RpcClientProvider } from '@/lib/rpc/RpcClientProvider'
|
||||
|
||||
const COLLAPSE_DELAY = 150
|
||||
@@ -25,10 +23,6 @@ export const SidebarLayout: FC = () => {
|
||||
setShortcutsDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
track(SETTINGS_PAGE_VIEWED_EVENT, { page: location.pathname })
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
}, [])
|
||||
@@ -103,11 +97,17 @@ export const SidebarLayout: FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Main content - full width, centered */}
|
||||
<main className="min-h-screen overflow-y-auto">
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{location.pathname === '/home/chat' ? (
|
||||
<main className="relative h-dvh overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
) : (
|
||||
<main className="min-h-screen overflow-y-auto">
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
<ShortcutsDialog
|
||||
open={shortcutsDialogOpen}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ChevronDown, Loader2, Sparkles, Undo2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod/v3'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
@@ -31,6 +35,15 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SCHEDULED_TASK_PROMPT_REFINED_EVENT } from '@/lib/constants/analyticsEvents'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import {
|
||||
defaultProviderIdStorage,
|
||||
providersStorage,
|
||||
} from '@/lib/llm-providers/storage'
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { refinePrompt } from '@/lib/schedules/refine-prompt'
|
||||
import type { ScheduledJob } from './types'
|
||||
|
||||
const formSchema = z
|
||||
@@ -43,6 +56,7 @@ const formSchema = z
|
||||
scheduleType: z.enum(['daily', 'hourly', 'minutes']),
|
||||
scheduleTime: z.string().optional(),
|
||||
scheduleInterval: z.number().int().min(1).max(60).optional(),
|
||||
providerId: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
@@ -81,6 +95,8 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
onSave,
|
||||
}) => {
|
||||
const isEditing = !!initialValues
|
||||
const [providers, setProviders] = useState<LlmProviderConfig[]>([])
|
||||
const [defaultProviderId, setDefaultProviderId] = useState<string>('')
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -90,14 +106,36 @@ 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,
|
||||
@@ -105,6 +143,7 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
scheduleType: initialValues.scheduleType,
|
||||
scheduleTime: initialValues.scheduleTime || '09:00',
|
||||
scheduleInterval: initialValues.scheduleInterval || 1,
|
||||
providerId: initialValues.providerId,
|
||||
enabled: initialValues.enabled,
|
||||
})
|
||||
} else {
|
||||
@@ -114,12 +153,87 @@ 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(),
|
||||
@@ -129,9 +243,11 @@ 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)
|
||||
}
|
||||
|
||||
@@ -169,22 +285,96 @@ export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
|
||||
name="query"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Prompt</FormLabel>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Prompt</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 px-2 py-1 text-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>
|
||||
<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>
|
||||
<FormDescription>
|
||||
The instruction that will be sent to the agent
|
||||
</FormDescription>
|
||||
{!isRefining && originalPromptRef.current !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-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>
|
||||
)}
|
||||
<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, useMemo, useState } from 'react'
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import { providersStorage } from '@/lib/llm-providers/storage'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
import { useScheduledJobRuns } from '@/lib/schedules/scheduleStorage'
|
||||
import type { ScheduledJob, ScheduledJobRun } from './types'
|
||||
|
||||
@@ -80,9 +83,25 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
|
||||
onRetryRun,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [providerInfo, setProviderInfo] = useState<{
|
||||
name: string
|
||||
type: ProviderType
|
||||
} | null>(null)
|
||||
|
||||
const { jobRuns } = useScheduledJobRuns()
|
||||
|
||||
// Load provider info for display
|
||||
useEffect(() => {
|
||||
if (!job.providerId) {
|
||||
setProviderInfo(null)
|
||||
return
|
||||
}
|
||||
providersStorage.getValue().then((providers) => {
|
||||
const match = providers?.find((p) => p.id === job.providerId)
|
||||
setProviderInfo(match ? { name: match.name, type: match.type } : null)
|
||||
})
|
||||
}, [job.providerId])
|
||||
|
||||
const runs = useMemo(
|
||||
() =>
|
||||
jobRuns
|
||||
@@ -117,6 +136,19 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<span>{formatSchedule(job)}</span>
|
||||
{providerInfo && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{providerInfo.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={12} />
|
||||
) : (
|
||||
<ProviderIcon type={providerInfo.type} size={12} />
|
||||
)}
|
||||
{providerInfo.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{job.lastRunAt && (
|
||||
<>
|
||||
<span>•</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertCircle, Pencil, Plus, Trash2, Wand2 } from 'lucide-react'
|
||||
import { AlertCircle, Eye, Pencil, Plus, Trash2, Wand2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -108,23 +109,19 @@ export const SkillsPage: FC = () => {
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && skills.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onEdit={() => handleEdit(skill)}
|
||||
onDelete={() => setSkillToDelete(skill)}
|
||||
onToggle={(enabled) => handleToggle(skill, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SkillSections
|
||||
skills={skills}
|
||||
onEdit={handleEdit}
|
||||
onDelete={(skill) => setSkillToDelete(skill)}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SkillDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
editingSkill={editingSkill}
|
||||
readOnly={editingSkill?.builtIn}
|
||||
onSave={async (data) => {
|
||||
try {
|
||||
if (editingSkill) {
|
||||
@@ -251,6 +248,50 @@ const EmptyState: FC<{ onCreateClick: () => void }> = ({ onCreateClick }) => (
|
||||
</Card>
|
||||
)
|
||||
|
||||
const SkillGrid: FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const SkillSections: FC<{
|
||||
skills: SkillMeta[]
|
||||
onEdit: (skill: SkillMeta) => void
|
||||
onDelete: (skill: SkillMeta) => void
|
||||
onToggle: (skill: SkillMeta, enabled: boolean) => void
|
||||
}> = ({ skills, onEdit, onDelete, onToggle }) => {
|
||||
const userSkills = skills.filter((s) => !s.builtIn)
|
||||
const builtInSkills = skills.filter((s) => s.builtIn)
|
||||
|
||||
const renderCard = (skill: SkillMeta) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onEdit={() => onEdit(skill)}
|
||||
onDelete={() => onDelete(skill)}
|
||||
onToggle={(enabled) => onToggle(skill, enabled)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{userSkills.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-sm">My Skills</h3>
|
||||
<SkillGrid>{userSkills.map(renderCard)}</SkillGrid>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{builtInSkills.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-sm">BrowserOS Skills</h3>
|
||||
<SkillGrid>{builtInSkills.map(renderCard)}</SkillGrid>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SkillCard: FC<{
|
||||
skill: SkillMeta
|
||||
onEdit: () => void
|
||||
@@ -260,7 +301,14 @@ const SkillCard: FC<{
|
||||
<Card className="h-full py-0 shadow-sm">
|
||||
<CardContent className="flex h-full flex-col p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="font-semibold text-sm leading-5">{skill.name}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-sm leading-5">{skill.name}</h2>
|
||||
{skill.builtIn ? (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||
Built-in
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
@@ -281,18 +329,29 @@ const SkillCard: FC<{
|
||||
onClick={onEdit}
|
||||
className="-ml-2 h-7 px-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="size-7 text-muted-foreground hover:bg-transparent hover:text-destructive"
|
||||
aria-label={`Delete ${skill.name}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{skill.builtIn ? (
|
||||
<>
|
||||
<Eye className="size-3.5" />
|
||||
View
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{!skill.builtIn ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onDelete}
|
||||
className="size-7 text-muted-foreground hover:bg-transparent hover:text-destructive"
|
||||
aria-label={`Delete ${skill.name}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -302,12 +361,13 @@ const SkillDialog: FC<{
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editingSkill: SkillDetail | null
|
||||
readOnly?: boolean
|
||||
onSave: (data: {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
}) => Promise<void>
|
||||
}> = ({ open, onOpenChange, editingSkill, onSave }) => {
|
||||
}> = ({ open, onOpenChange, editingSkill, readOnly, onSave }) => {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
@@ -354,12 +414,18 @@ const SkillDialog: FC<{
|
||||
<DialogContent className="flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl">
|
||||
<DialogHeader className="border-b px-6 py-5">
|
||||
<DialogTitle>
|
||||
{editingSkill ? 'Edit Skill' : 'Create Skill'}
|
||||
{readOnly
|
||||
? 'View Skill'
|
||||
: editingSkill
|
||||
? 'Edit Skill'
|
||||
: 'Create Skill'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingSkill
|
||||
? 'Refine when the agent should use this skill and how it should execute it.'
|
||||
: 'Define a reusable instruction set your agent can apply when a request matches.'}
|
||||
{readOnly
|
||||
? 'This skill is managed by BrowserOS and updated automatically.'
|
||||
: editingSkill
|
||||
? 'Refine when the agent should use this skill and how it should execute it.'
|
||||
: 'Define a reusable instruction set your agent can apply when a request matches.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -373,6 +439,7 @@ const SkillDialog: FC<{
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
maxLength={100}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs leading-5">
|
||||
Keep it short and recognizable in the skills list.
|
||||
@@ -388,19 +455,22 @@ const SkillDialog: FC<{
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
maxLength={500}
|
||||
className="min-h-28 resize-none bg-background"
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs leading-5">
|
||||
This is the trigger summary the agent uses to pick the skill.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto rounded-lg border border-border/60 border-dashed bg-muted/30 px-3 py-2.5">
|
||||
<p className="font-medium text-muted-foreground text-xs">Tip</p>
|
||||
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-muted-foreground text-xs leading-5">
|
||||
<li>List the ordered steps the agent should follow.</li>
|
||||
<li>Close with the output or formatting you expect back.</li>
|
||||
</ul>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<div className="mt-auto rounded-lg border border-border/60 border-dashed bg-muted/30 px-3 py-2.5">
|
||||
<p className="font-medium text-muted-foreground text-xs">Tip</p>
|
||||
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-muted-foreground text-xs leading-5">
|
||||
<li>List the ordered steps the agent should follow.</li>
|
||||
<li>Close with the output or formatting you expect back.</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col px-6 py-5">
|
||||
@@ -411,36 +481,52 @@ const SkillDialog: FC<{
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<MarkdownEditor
|
||||
id="skill-content"
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
onKeyDown={handleContentKeyDown}
|
||||
placeholder="Write instructions for the agent. Use markdown for structure."
|
||||
className="mt-4 min-h-[320px] flex-1 overflow-y-auto text-sm"
|
||||
/>
|
||||
{readOnly ? (
|
||||
<div className="prose prose-sm dark:prose-invert mt-4 min-h-[320px] max-w-none flex-1 overflow-y-auto rounded-md border p-4 text-sm">
|
||||
<Markdown>{content}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
id="skill-content"
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
onKeyDown={handleContentKeyDown}
|
||||
placeholder="Write instructions for the agent. Use markdown for structure."
|
||||
className="mt-4 min-h-[320px] flex-1 overflow-y-auto text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Saved locally and available to your agent immediately.
|
||||
{readOnly
|
||||
? 'This skill is managed by BrowserOS and updated automatically.'
|
||||
: 'Saved locally and available to your agent immediately.'}
|
||||
</p>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || saving}>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: editingSkill
|
||||
? 'Update Skill'
|
||||
: 'Create Skill'}
|
||||
</Button>
|
||||
{readOnly ? (
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || saving}>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: editingSkill
|
||||
? 'Update Skill'
|
||||
: 'Create Skill'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SkillMeta = {
|
||||
description: string
|
||||
location: string
|
||||
enabled: boolean
|
||||
builtIn: boolean
|
||||
}
|
||||
|
||||
export type SkillDetail = SkillMeta & {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
syncScheduledJobs,
|
||||
} from '@/lib/schedules/scheduleStorage'
|
||||
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
|
||||
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
|
||||
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
|
||||
import { scheduledJobRuns } from './scheduledJobRuns'
|
||||
|
||||
@@ -66,7 +67,12 @@ export default defineBackground(() => {
|
||||
}
|
||||
})
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message?.type === 'get-tab-id') {
|
||||
sendResponse({ tabId: sender.tab?.id })
|
||||
return true
|
||||
}
|
||||
|
||||
if (message?.type === 'AUTH_SUCCESS' && sender.tab?.id) {
|
||||
const tabId = sender.tab.id
|
||||
authRedirectPathStorage
|
||||
@@ -93,6 +99,17 @@ export default defineBackground(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up selected text storage when a tab is closed
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
const key = String(tabId)
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
if (map[key]) {
|
||||
const { [key]: _, ...rest } = map
|
||||
selectedTextStorage.setValue(rest)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
sessionStorage.watch(async (newSession) => {
|
||||
if (newSession?.user?.id) {
|
||||
try {
|
||||
|
||||
@@ -117,6 +117,7 @@ export const scheduledJobRuns = async () => {
|
||||
const response = await getChatServerResponse({
|
||||
message: job.query,
|
||||
signal: abortController.signal,
|
||||
providerId: job.providerId,
|
||||
})
|
||||
|
||||
await updateJobRun(jobRun.id, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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,
|
||||
@@ -48,7 +49,9 @@ 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">Import your data</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{i18n.t('newtab.importData.title')}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -60,7 +63,7 @@ export const ImportDataHint = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Bring bookmarks, history, and passwords from Chrome.
|
||||
{i18n.t('newtab.importData.description')}
|
||||
</CardDescription>
|
||||
<label
|
||||
htmlFor="import-dont-ask-again"
|
||||
@@ -73,11 +76,11 @@ export const ImportDataHint = () => {
|
||||
setDontAskAgain(checked === true)
|
||||
}
|
||||
/>
|
||||
Don't show this again
|
||||
{i18n.t('newtab.importData.dontShowAgain')}
|
||||
</label>
|
||||
<Button className="w-full" onClick={handleImport}>
|
||||
<Upload className="size-4" />
|
||||
Open Import Settings
|
||||
{i18n.t('newtab.importData.openSettings')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
@@ -11,6 +11,9 @@ 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,
|
||||
@@ -36,7 +39,6 @@ import {
|
||||
import {
|
||||
NEWTAB_AI_TRIGGERED_EVENT,
|
||||
NEWTAB_APPS_OPENED_EVENT,
|
||||
NEWTAB_CHAT_RESET_EVENT,
|
||||
NEWTAB_CHAT_STARTED_EVENT,
|
||||
NEWTAB_OPENED_EVENT,
|
||||
NEWTAB_SEARCH_EXECUTED_EVENT,
|
||||
@@ -45,6 +47,8 @@ import {
|
||||
NEWTAB_TABS_OPENED_EVENT,
|
||||
NEWTAB_WORKSPACE_OPENED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
|
||||
import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
|
||||
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
|
||||
@@ -58,7 +62,6 @@ import {
|
||||
useSuggestions,
|
||||
} from './lib/suggestions/useSuggestions'
|
||||
import { NewTabBranding } from './NewTabBranding'
|
||||
import { NewTabChat } from './NewTabChat'
|
||||
import { NewTabTip } from './NewTabTip'
|
||||
import { ScheduleResults } from './ScheduleResults'
|
||||
import { SearchSuggestions } from './SearchSuggestions'
|
||||
@@ -78,13 +81,13 @@ interface MentionState {
|
||||
*/
|
||||
export const NewTab = () => {
|
||||
const activeHint = useActiveHint()
|
||||
const navigate = useNavigate()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const tabsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [shortcutsDialogOpen, setShortcutsDialogOpen] = useState(false)
|
||||
const [chatActive, setChatActive] = useState(false)
|
||||
const [mentionState, setMentionState] = useState<MentionState>({
|
||||
isOpen: false,
|
||||
filterText: '',
|
||||
@@ -92,13 +95,12 @@ export const NewTab = () => {
|
||||
})
|
||||
const { selectedFolder } = useWorkspace()
|
||||
const { supports } = useCapabilities()
|
||||
const { providers, selectedProvider, handleSelectProvider } =
|
||||
useChatSessionContext()
|
||||
const { servers: mcpServers } = useMcpServers()
|
||||
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
|
||||
useSyncRemoteIntegrations()
|
||||
|
||||
const { messages, sendMessage, setMode, resetConversation } =
|
||||
useChatSessionContext()
|
||||
|
||||
const connectedManagedServers = mcpServers.filter((s) => {
|
||||
if (s.type !== 'managed' || !s.managedServerName) return false
|
||||
return userMCPIntegrations?.integrations?.find(
|
||||
@@ -128,7 +130,9 @@ export const NewTab = () => {
|
||||
query: inputValue,
|
||||
selectedTabs,
|
||||
})
|
||||
const searchPlaceholder = `Ask BrowserOS or search ${providerConfig.name}...`
|
||||
const searchPlaceholder = i18n.t('newtab.search.placeholder', [
|
||||
providerConfig.name,
|
||||
])
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
@@ -275,17 +279,28 @@ export const NewTab = () => {
|
||||
|
||||
const startInlineChat = (
|
||||
message: string,
|
||||
mode: 'chat' | 'agent',
|
||||
action?: ReturnType<
|
||||
typeof createBrowserOSAction | typeof createAITabAction
|
||||
>,
|
||||
chatMode: 'chat' | 'agent',
|
||||
aiTab?: { name: string; description: string },
|
||||
) => {
|
||||
track(NEWTAB_CHAT_STARTED_EVENT, { mode, tabs_count: selectedTabs.length })
|
||||
setMode(mode)
|
||||
setChatActive(true)
|
||||
sendMessage({ text: message, action })
|
||||
track(NEWTAB_CHAT_STARTED_EVENT, {
|
||||
mode: chatMode,
|
||||
tabs_count: selectedTabs.length,
|
||||
})
|
||||
const tabIds = selectedTabs
|
||||
.map((t) => t.id)
|
||||
.filter((id): id is number => id !== undefined)
|
||||
reset()
|
||||
setSelectedTabs([])
|
||||
const params = new URLSearchParams({ q: message, mode: chatMode })
|
||||
if (tabIds.length > 0) {
|
||||
params.set('tabs', tabIds.join(','))
|
||||
}
|
||||
if (aiTab) {
|
||||
params.set('actionType', 'ai-tab')
|
||||
params.set('tabName', aiTab.name)
|
||||
params.set('tabDescription', aiTab.description)
|
||||
}
|
||||
navigate(`/home/chat?${params.toString()}`)
|
||||
}
|
||||
|
||||
const runSelectedAction = (item: SuggestionItem | undefined) => {
|
||||
@@ -306,15 +321,18 @@ export const NewTab = () => {
|
||||
mode: 'agent',
|
||||
tabs_count: selectedTabs.length,
|
||||
})
|
||||
const action = createAITabAction({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
const searchQuery = `${item.name}${item.description ? ` - ${item.description}` : ''}}`
|
||||
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
|
||||
startInlineChat(searchQuery, 'agent', action)
|
||||
startInlineChat(searchQuery, 'agent', {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
})
|
||||
} else {
|
||||
const action = createAITabAction({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
openSidePanelWithSearch('open', {
|
||||
query: searchQuery,
|
||||
mode: 'agent',
|
||||
@@ -330,14 +348,14 @@ export const NewTab = () => {
|
||||
mode: item.mode,
|
||||
tabs_count: selectedTabs.length,
|
||||
})
|
||||
const action = createBrowserOSAction({
|
||||
mode: item.mode,
|
||||
message: item.message,
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
|
||||
startInlineChat(item.message, item.mode, action)
|
||||
startInlineChat(item.message, item.mode)
|
||||
} else {
|
||||
const action = createBrowserOSAction({
|
||||
mode: item.mode,
|
||||
message: item.message,
|
||||
tabs: selectedTabs,
|
||||
})
|
||||
openSidePanelWithSearch('open', {
|
||||
query: item.message,
|
||||
mode: item.mode,
|
||||
@@ -351,12 +369,6 @@ export const NewTab = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackToSearch = () => {
|
||||
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
|
||||
resetConversation()
|
||||
setChatActive(false)
|
||||
}
|
||||
|
||||
const isSuggestionsVisible =
|
||||
!mentionState.isOpen &&
|
||||
((isOpen && inputValue.length) ||
|
||||
@@ -368,10 +380,6 @@ export const NewTab = () => {
|
||||
track(NEWTAB_OPENED_EVENT)
|
||||
}, [])
|
||||
|
||||
if (chatActive) {
|
||||
return <NewTabChat onBackToSearch={handleBackToSearch} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-[max(25vh,16px)]">
|
||||
{/* Main content */}
|
||||
@@ -490,7 +498,7 @@ export const NewTab = () => {
|
||||
{selectedTab.title}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Tab
|
||||
{i18n.t('newtab.selectedTab')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -524,6 +532,34 @@ export const NewTab = () => {
|
||||
{mounted && (
|
||||
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedProvider && (
|
||||
<ChatProviderSelector
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={selectedProvider.name}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-lg transition-all',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'data-[state=open]:bg-accent',
|
||||
)}
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={16} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
type={selectedProvider.type as ProviderType}
|
||||
size={16}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</ChatProviderSelector>
|
||||
)}
|
||||
|
||||
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) && (
|
||||
<WorkspaceSelector>
|
||||
<Button
|
||||
@@ -536,7 +572,10 @@ export const NewTab = () => {
|
||||
)}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>{selectedFolder?.name || 'Add workspace'}</span>
|
||||
<span>
|
||||
{selectedFolder?.name ||
|
||||
i18n.t('newtab.addWorkspace')}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</WorkspaceSelector>
|
||||
@@ -559,7 +598,7 @@ export const NewTab = () => {
|
||||
)}
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
<span>Tabs</span>
|
||||
<span>{i18n.t('common.tabs')}</span>
|
||||
</Button>
|
||||
</TabPickerPopover>
|
||||
</div>
|
||||
@@ -569,7 +608,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">
|
||||
New!
|
||||
{i18n.t('newtab.appsNew')}
|
||||
</span>
|
||||
)}
|
||||
{connectedManagedServers.length === 0 ? (
|
||||
@@ -590,14 +629,13 @@ export const NewTab = () => {
|
||||
)}
|
||||
>
|
||||
<PlugZap className="h-4 w-4" />
|
||||
<span>Apps</span>
|
||||
<span>{i18n.t('common.apps')}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</AppSelector>
|
||||
<TooltipContent side="left" className="max-w-56">
|
||||
Apps directly connected will have more accurate and
|
||||
faster responses for your queries!
|
||||
{i18n.t('newtab.appsTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -634,7 +672,7 @@ export const NewTab = () => {
|
||||
+{connectedManagedServers.length - 4}
|
||||
</span>
|
||||
)}
|
||||
<span>Apps</span>
|
||||
<span>{i18n.t('common.apps')}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</AppSelector>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 = () => {
|
||||
@@ -15,7 +16,11 @@ export const NewTabBranding: FC = () => {
|
||||
}}
|
||||
className="flex h-20 w-20 items-center justify-center rounded-xl bg-transparent"
|
||||
>
|
||||
<img src={ProductLogoSvg} alt="BrowserOS" className="h-20 w-20" />
|
||||
<img
|
||||
src={ProductLogoSvg}
|
||||
alt={i18n.t('newtab.branding.alt')}
|
||||
className="h-20 w-20"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ChatEmptyState } from '@/entrypoints/sidepanel/index/ChatEmptyState'
|
||||
import { ChatError } from '@/entrypoints/sidepanel/index/ChatError'
|
||||
import { ChatFooter } from '@/entrypoints/sidepanel/index/ChatFooter'
|
||||
import { ChatHeader } from '@/entrypoints/sidepanel/index/ChatHeader'
|
||||
import { ChatMessages } from '@/entrypoints/sidepanel/index/ChatMessages'
|
||||
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
|
||||
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
|
||||
import { createBrowserOSAction } from '@/lib/chat-actions/types'
|
||||
import {
|
||||
createAITabAction,
|
||||
createBrowserOSAction,
|
||||
} from '@/lib/chat-actions/types'
|
||||
import { useChatActions } from '@/lib/chat-actions/useChatActions'
|
||||
import {
|
||||
NEWTAB_AI_TRIGGERED_EVENT,
|
||||
NEWTAB_CHAT_MODE_CHANGED_EVENT,
|
||||
NEWTAB_CHAT_RESET_EVENT,
|
||||
NEWTAB_CHAT_STOPPED_EVENT,
|
||||
NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT,
|
||||
NEWTAB_TAB_REMOVED_EVENT,
|
||||
NEWTAB_TAB_TOGGLED_EVENT,
|
||||
NEWTAB_VOICE_ERROR_EVENT,
|
||||
NEWTAB_VOICE_RECORDING_STARTED_EVENT,
|
||||
NEWTAB_VOICE_RECORDING_STOPPED_EVENT,
|
||||
NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { NewTabChatHeader } from './NewTabChatHeader'
|
||||
|
||||
interface NewTabChatProps {
|
||||
onBackToSearch: () => void
|
||||
}
|
||||
export const NewTabChat: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const hasSentInitialRef = useRef(false)
|
||||
|
||||
export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
const {
|
||||
mode,
|
||||
setMode,
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
agentUrlError,
|
||||
chatError,
|
||||
getActionForMessage,
|
||||
@@ -42,71 +48,80 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
selectedProvider,
|
||||
handleSelectProvider,
|
||||
resetConversation,
|
||||
} = useChatSessionContext()
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [mounted, setMounted] = useState(false)
|
||||
input,
|
||||
setInput,
|
||||
attachedTabs,
|
||||
mounted,
|
||||
voiceState,
|
||||
handleModeChange,
|
||||
handleStop,
|
||||
toggleTabSelection,
|
||||
removeTab,
|
||||
handleSubmit,
|
||||
handleSuggestionClick,
|
||||
} = useChatActions({
|
||||
events: {
|
||||
modeChanged: NEWTAB_CHAT_MODE_CHANGED_EVENT,
|
||||
stopClicked: NEWTAB_CHAT_STOPPED_EVENT,
|
||||
suggestionClicked: NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT,
|
||||
tabToggled: NEWTAB_TAB_TOGGLED_EVENT,
|
||||
tabRemoved: NEWTAB_TAB_REMOVED_EVENT,
|
||||
aiTriggered: NEWTAB_AI_TRIGGERED_EVENT,
|
||||
voiceRecordingStarted: NEWTAB_VOICE_RECORDING_STARTED_EVENT,
|
||||
voiceRecordingStopped: NEWTAB_VOICE_RECORDING_STOPPED_EVENT,
|
||||
voiceTranscriptionCompleted: NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT,
|
||||
voiceError: NEWTAB_VOICE_ERROR_EVENT,
|
||||
},
|
||||
})
|
||||
|
||||
// Send the initial message from URL query params (from /home search bar).
|
||||
// Guarded by ref to prevent double-fire in React Strict Mode.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: must only run once on mount
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
if (hasSentInitialRef.current) return
|
||||
const query = searchParams.get('q')
|
||||
const chatMode = searchParams.get('mode')
|
||||
const tabIdsParam = searchParams.get('tabs')
|
||||
if (!query) return
|
||||
|
||||
const handleModeChange = (newMode: ChatMode) => {
|
||||
track(NEWTAB_CHAT_MODE_CHANGED_EVENT, { from: mode, to: newMode })
|
||||
setMode(newMode)
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
track(NEWTAB_CHAT_STOPPED_EVENT)
|
||||
stop()
|
||||
}
|
||||
|
||||
const toggleTabSelection = (tab: chrome.tabs.Tab) => {
|
||||
setAttachedTabs((prev) => {
|
||||
const isSelected = prev.some((t) => t.id === tab.id)
|
||||
track(NEWTAB_TAB_TOGGLED_EVENT, {
|
||||
action: isSelected ? 'removed' : 'added',
|
||||
})
|
||||
if (isSelected) {
|
||||
return prev.filter((t) => t.id !== tab.id)
|
||||
}
|
||||
return [...prev, tab]
|
||||
})
|
||||
}
|
||||
|
||||
const removeTab = (tabId?: number) => {
|
||||
track(NEWTAB_TAB_REMOVED_EVENT)
|
||||
setAttachedTabs((prev) => prev.filter((t) => t.id !== tabId))
|
||||
}
|
||||
|
||||
const executeMessage = (customMessageText?: string) => {
|
||||
const messageText = customMessageText ? customMessageText : input.trim()
|
||||
if (!messageText) return
|
||||
|
||||
if (attachedTabs.length) {
|
||||
const action = createBrowserOSAction({
|
||||
mode,
|
||||
message: messageText,
|
||||
tabs: attachedTabs,
|
||||
})
|
||||
sendMessage({ text: messageText, action })
|
||||
} else {
|
||||
sendMessage({ text: messageText })
|
||||
hasSentInitialRef.current = true
|
||||
if (chatMode === 'chat' || chatMode === 'agent') {
|
||||
setMode(chatMode)
|
||||
}
|
||||
setInput('')
|
||||
setAttachedTabs([])
|
||||
}
|
||||
setSearchParams({}, { replace: true })
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
executeMessage()
|
||||
}
|
||||
const actionType = searchParams.get('actionType')
|
||||
const tabName = searchParams.get('tabName')
|
||||
const tabDescription = searchParams.get('tabDescription')
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
track(NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT, { mode })
|
||||
executeMessage(suggestion)
|
||||
}
|
||||
if (tabIdsParam) {
|
||||
const tabIds = tabIdsParam.split(',').map(Number).filter(Boolean)
|
||||
chrome.tabs.query({}).then((allTabs) => {
|
||||
const matchedTabs = allTabs.filter(
|
||||
(t) => t.id !== undefined && tabIds.includes(t.id),
|
||||
)
|
||||
if (matchedTabs.length > 0) {
|
||||
const action =
|
||||
actionType === 'ai-tab' && tabName
|
||||
? createAITabAction({
|
||||
name: tabName,
|
||||
description: tabDescription ?? '',
|
||||
tabs: matchedTabs,
|
||||
})
|
||||
: createBrowserOSAction({
|
||||
mode: (chatMode as 'chat' | 'agent') ?? 'agent',
|
||||
message: query,
|
||||
tabs: matchedTabs,
|
||||
})
|
||||
sendMessage({ text: query, action })
|
||||
} else {
|
||||
sendMessage({ text: query })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
sendMessage({ text: query })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleNewConversation = () => {
|
||||
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
|
||||
@@ -116,17 +131,19 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
if (!selectedProvider) return null
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-2rem)] flex-col">
|
||||
<NewTabChatHeader
|
||||
selectedProvider={selectedProvider}
|
||||
providers={providers}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
onNewConversation={handleNewConversation}
|
||||
onBackToSearch={onBackToSearch}
|
||||
hasMessages={messages.length > 0}
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col overflow-hidden">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<ChatHeader
|
||||
selectedProvider={selectedProvider}
|
||||
providers={providers}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
onNewConversation={handleNewConversation}
|
||||
hasMessages={messages.length > 0}
|
||||
hideHistory
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col space-y-4 overflow-y-auto px-4 pt-4">
|
||||
<main className="styled-scrollbar [&_[data-streamdown='code-block']]:!max-w-full [&_[data-streamdown='code-block']]:!w-auto [&_[data-streamdown='table-wrapper']]:!max-w-full [&_[data-streamdown='table-wrapper']]:!w-auto mx-auto flex min-h-0 w-full max-w-3xl flex-1 flex-col space-y-4 overflow-y-auto overflow-x-hidden px-4 pt-4 [&_[data-streamdown='code-block']]:overflow-x-auto [&_[data-streamdown='table-wrapper']]:overflow-x-auto">
|
||||
{isRestoringConversation ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
@@ -156,7 +173,7 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
{chatError && <ChatError error={chatError} />}
|
||||
</main>
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl px-4">
|
||||
<div className="mx-auto w-full max-w-3xl flex-shrink-0 px-4 pb-2">
|
||||
<ChatFooter
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
@@ -168,6 +185,7 @@ export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
|
||||
attachedTabs={attachedTabs}
|
||||
onToggleTab={toggleTabSelection}
|
||||
onRemoveTab={removeTab}
|
||||
voice={voiceState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { ArrowLeft, Plus } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
|
||||
import type { Provider } from '@/components/chat/chatComponentTypes'
|
||||
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
|
||||
import type { ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
interface NewTabChatHeaderProps {
|
||||
selectedProvider: Provider
|
||||
providers: Provider[]
|
||||
onSelectProvider: (provider: Provider) => void
|
||||
onNewConversation: () => void
|
||||
onBackToSearch: () => void
|
||||
hasMessages: boolean
|
||||
}
|
||||
|
||||
export const NewTabChatHeader: FC<NewTabChatHeaderProps> = ({
|
||||
selectedProvider,
|
||||
providers,
|
||||
onSelectProvider,
|
||||
onNewConversation,
|
||||
onBackToSearch,
|
||||
hasMessages,
|
||||
}) => {
|
||||
return (
|
||||
<header className="flex items-center justify-between border-border/40 border-b bg-background/80 px-4 py-2.5 backdrop-blur-md">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Back to search */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToSearch}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Back to search"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Provider selector */}
|
||||
<ChatProviderSelector
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelectProvider={onSelectProvider}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group relative inline-flex cursor-pointer items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
|
||||
title="Change AI Provider"
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={18} />
|
||||
) : (
|
||||
<ProviderIcon
|
||||
type={selectedProvider.type as ProviderType}
|
||||
size={18}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold text-base">
|
||||
{selectedProvider.name}
|
||||
</span>
|
||||
</button>
|
||||
</ChatProviderSelector>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{hasMessages && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewConversation}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -39,7 +40,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)]">
|
||||
Tip:
|
||||
{i18n.t('newtab.tip.label')}
|
||||
</span>{' '}
|
||||
{tip.text}
|
||||
</p>
|
||||
@@ -47,7 +48,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="Next tip"
|
||||
title={i18n.t('newtab.tip.nextTip')}
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -55,7 +56,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="Dismiss"
|
||||
title={i18n.t('newtab.tip.dismiss')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { i18n } from '#i18n'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,10 +26,10 @@ export const ShortcutsDialog = ({
|
||||
<DialogContent className="styled-scrollbar max-h-[80vh] max-w-xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-semibold text-2xl">
|
||||
Keyboard Shortcuts
|
||||
{i18n.t('newtab.shortcuts.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Use these shortcuts to navigate BrowserOS faster
|
||||
{i18n.t('newtab.shortcuts.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -59,7 +60,7 @@ export const ShortcutsDialog = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-8 border-border/50 border-t pt-4 text-center text-muted-foreground text-xs">
|
||||
More shortcuts coming soon
|
||||
{i18n.t('newtab.shortcuts.moreComingSoon')}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -47,7 +48,9 @@ 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">Sync your data</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{i18n.t('newtab.signIn.title')}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -59,7 +62,7 @@ export const SignInHint = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Sign in to sync conversation history to the cloud.
|
||||
{i18n.t('newtab.signIn.description')}
|
||||
</CardDescription>
|
||||
<label
|
||||
htmlFor="sync-dont-ask-again"
|
||||
@@ -72,10 +75,10 @@ export const SignInHint = () => {
|
||||
setDontAskAgain(checked === true)
|
||||
}
|
||||
/>
|
||||
Don't ask again
|
||||
{i18n.t('newtab.signIn.dontAskAgain')}
|
||||
</label>
|
||||
<Button className="w-full" onClick={() => navigate('/login')}>
|
||||
Sign in
|
||||
{i18n.t('newtab.signIn.signInButton')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
@@ -3,14 +3,19 @@ import { Outlet, useLocation } from 'react-router'
|
||||
import { ChatSessionProvider } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
|
||||
import { NewTabFocusGrid } from './NewTabFocusGrid'
|
||||
|
||||
const HIDE_FOCUS_GRID_PATHS = new Set([
|
||||
'/home/soul',
|
||||
'/home/memory',
|
||||
'/home/skills',
|
||||
'/home/chat',
|
||||
])
|
||||
|
||||
export const NewTabLayout: FC = () => {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<ChatSessionProvider origin="newtab">
|
||||
{location.pathname !== '/home/soul' &&
|
||||
location.pathname !== '/home/memory' &&
|
||||
location.pathname !== '/home/skills' && <NewTabFocusGrid />}
|
||||
{!HIDE_FOCUS_GRID_PATHS.has(location.pathname) && <NewTabFocusGrid />}
|
||||
<Outlet />
|
||||
</ChatSessionProvider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
|
||||
|
||||
const MAX_SELECTED_TEXT_LENGTH = 5000
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['*://*/*'],
|
||||
runAt: 'document_idle',
|
||||
async main() {
|
||||
const response = await chrome.runtime.sendMessage({ type: 'get-tab-id' })
|
||||
const tabId: number | undefined = response?.tabId
|
||||
if (!tabId) return
|
||||
|
||||
const key = String(tabId)
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
const text = window.getSelection()?.toString().trim()
|
||||
|
||||
if (text && text.length > 0) {
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
selectedTextStorage.setValue({
|
||||
...map,
|
||||
[key]: {
|
||||
text: text.slice(0, MAX_SELECTED_TEXT_LENGTH),
|
||||
pageUrl: window.location.href,
|
||||
pageTitle: document.title,
|
||||
tabId,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// User clicked without selecting — clear this tab's entry only
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
if (map[key]) {
|
||||
const { [key]: _, ...rest } = map
|
||||
selectedTextStorage.setValue(rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -8,9 +8,14 @@ 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'
|
||||
@@ -48,6 +53,8 @@ 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)
|
||||
@@ -83,6 +90,26 @@ 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)
|
||||
@@ -147,6 +174,27 @@ 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">
|
||||
@@ -190,6 +238,7 @@ export const Chat = () => {
|
||||
attachedTabs={attachedTabs}
|
||||
onToggleTab={toggleTabSelection}
|
||||
onRemoveTab={removeTab}
|
||||
voice={voiceState}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Globe, X } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
|
||||
interface ChatAttachedTabsProps {
|
||||
tabs: chrome.tabs.Tab[]
|
||||
@@ -34,7 +35,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="Remove tab"
|
||||
title={i18n.t('attachedTabs.removeTab')}
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
|
||||
@@ -28,12 +29,14 @@ export const ChatEmptyState: FC<ChatEmptyStateProps> = ({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-1 font-semibold text-lg">
|
||||
{mode === 'chat' ? 'Chat with this page' : 'Agent at your service'}
|
||||
{mode === 'chat'
|
||||
? i18n.t('chat.empty.chatTitle')
|
||||
: i18n.t('chat.empty.agentTitle')}
|
||||
</h2>
|
||||
<p className="max-w-[200px] text-muted-foreground text-xs">
|
||||
{mode === 'chat'
|
||||
? 'Ask questions about the current page or any topic'
|
||||
: 'Let AI automate tasks and browse for you'}
|
||||
? i18n.t('chat.empty.chatSubtitle')
|
||||
: i18n.t('chat.empty.agentSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
@@ -38,7 +39,7 @@ function parseErrorMessage(message: string): {
|
||||
message.includes('127.0.0.1')
|
||||
) {
|
||||
return {
|
||||
text: 'Unable to connect to BrowserOS agent. Follow below instructions.',
|
||||
text: i18n.t('chat.error.connectionMessage'),
|
||||
url: 'https://docs.browseros.com/troubleshooting/connection-issues',
|
||||
isConnectionError: true,
|
||||
}
|
||||
@@ -47,7 +48,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: 'Add your own API key for unlimited usage.',
|
||||
text: i18n.t('chat.error.rateLimitMessage'),
|
||||
url: 'https://dub.sh/browseros-usage-limit',
|
||||
isRateLimit: true,
|
||||
}
|
||||
@@ -83,9 +84,9 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
// --- End commented out survey code ---
|
||||
|
||||
const getTitle = () => {
|
||||
if (isRateLimit) return 'Daily limit reached'
|
||||
if (isConnectionError) return 'Connection failed'
|
||||
return 'Something went wrong'
|
||||
if (isRateLimit) return i18n.t('chat.error.dailyLimitTitle')
|
||||
if (isConnectionError) return i18n.t('chat.error.connectionFailedTitle')
|
||||
return i18n.t('chat.error.genericTitle')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -102,7 +103,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground text-xs underline hover:text-foreground"
|
||||
>
|
||||
View troubleshooting guide
|
||||
{i18n.t('chat.error.troubleshootingLink')}
|
||||
</a>
|
||||
)}
|
||||
{/* --- Commented out for Kimi partnership launch (restore after) ---
|
||||
@@ -139,7 +140,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
className="underline hover:text-foreground"
|
||||
onClick={() => track(KIMI_RATE_LIMIT_DOCS_CLICKED_EVENT)}
|
||||
>
|
||||
Learn how to get a Kimi API key
|
||||
{i18n.t('chat.error.kimiApiKeyLink')}
|
||||
</a>
|
||||
{' or '}
|
||||
<a
|
||||
@@ -149,7 +150,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
className="underline hover:text-foreground"
|
||||
onClick={() => track(KIMI_RATE_LIMIT_PLATFORM_CLICKED_EVENT)}
|
||||
>
|
||||
get your API key
|
||||
{i18n.t('chat.error.getApiKey')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -162,7 +163,7 @@ export const ChatError: FC<ChatErrorProps> = ({ error, onRetry }) => {
|
||||
className="mt-1 gap-2"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Try again
|
||||
{i18n.t('chat.error.tryAgain')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -8,12 +9,17 @@ import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetU
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
import { useCapabilities } from '@/lib/browseros/useCapabilities'
|
||||
import { useMcpServers } from '@/lib/mcp/mcpServerStorage'
|
||||
import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
|
||||
import {
|
||||
type SelectedTextData,
|
||||
selectedTextStorage,
|
||||
} from '@/lib/selected-text/selectedTextStorage'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VoiceInputState } from '@/lib/voice/useVoiceInput'
|
||||
import { useWorkspace } from '@/lib/workspace/use-workspace'
|
||||
import { ChatAttachedTabs } from './ChatAttachedTabs'
|
||||
import { ChatInput, type ChatInputHandle } from './ChatInput'
|
||||
import { ChatModeToggle } from './ChatModeToggle'
|
||||
import { ChatSelectedText } from './ChatSelectedText'
|
||||
import type { ChatMode } from './chatTypes'
|
||||
|
||||
interface ChatFooterProps {
|
||||
@@ -27,6 +33,7 @@ interface ChatFooterProps {
|
||||
attachedTabs: chrome.tabs.Tab[]
|
||||
onToggleTab: (tab: chrome.tabs.Tab) => void
|
||||
onRemoveTab: (tabId?: number) => void
|
||||
voice?: VoiceInputState
|
||||
}
|
||||
|
||||
export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
@@ -40,13 +47,40 @@ 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(() => {
|
||||
@@ -80,6 +114,19 @@ export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
return (
|
||||
<footer className="border-border/40 border-t bg-background/80 backdrop-blur-md">
|
||||
<ChatAttachedTabs tabs={attachedTabs} onRemoveTab={onRemoveTab} />
|
||||
{visibleSelectedText && (
|
||||
<ChatSelectedText
|
||||
selectedText={visibleSelectedText}
|
||||
onDismiss={() => {
|
||||
if (!activeTabId) return
|
||||
const key = String(activeTabId)
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
const { [key]: _, ...rest } = map
|
||||
selectedTextStorage.setValue(rest)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -96,7 +143,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="Attach tabs (@)"
|
||||
title={i18n.t('footer.attachTabs')}
|
||||
>
|
||||
<Layers className="h-4 w-4" />
|
||||
{attachedTabs.length > 0 && (
|
||||
@@ -120,7 +167,7 @@ export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
title={
|
||||
selectedFolder
|
||||
? selectedFolder.name
|
||||
: 'Select workspace folder'
|
||||
: i18n.t('footer.selectWorkspace')
|
||||
}
|
||||
>
|
||||
<div className="relative">
|
||||
@@ -139,7 +186,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="Connect apps"
|
||||
title={i18n.t('footer.connectApps')}
|
||||
>
|
||||
{connectedManagedServers.length > 0 ? (
|
||||
<>
|
||||
@@ -172,6 +219,10 @@ 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}
|
||||
@@ -182,6 +233,7 @@ export const ChatFooter: FC<ChatFooterProps> = ({
|
||||
selectedTabs={attachedTabs}
|
||||
onToggleTab={onToggleTab}
|
||||
onTabMentionOpenChange={setIsTabMentionOpen}
|
||||
voice={voice}
|
||||
ref={chatInputRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -14,6 +15,7 @@ interface ChatHeaderProps {
|
||||
onSelectProvider: (provider: Provider) => void
|
||||
onNewConversation: () => void
|
||||
hasMessages: boolean
|
||||
hideHistory?: boolean
|
||||
}
|
||||
|
||||
export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
@@ -22,6 +24,7 @@ export const ChatHeader: FC<ChatHeaderProps> = ({
|
||||
onSelectProvider,
|
||||
onNewConversation,
|
||||
hasMessages,
|
||||
hideHistory,
|
||||
}) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
@@ -44,7 +47,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="Change AI Provider"
|
||||
title={i18n.t('chat.header.changeProvider')}
|
||||
>
|
||||
{selectedProvider.type === 'browseros' ? (
|
||||
<BrowserOSIcon size={18} />
|
||||
@@ -67,37 +70,38 @@ 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="New conversation"
|
||||
title={i18n.t('chat.header.newConversation')}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isHistoryPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewConversationFromHistory}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/history"
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title="Chat history"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
{!hideHistory &&
|
||||
(isHistoryPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewConversationFromHistory}
|
||||
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
title={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>
|
||||
))}
|
||||
|
||||
<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="Star on Github"
|
||||
title={i18n.t('chat.header.starOnGithub')}
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
</a>
|
||||
@@ -107,7 +111,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="Settings"
|
||||
title={i18n.t('chat.header.settings')}
|
||||
>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Send, SquareStop } from 'lucide-react'
|
||||
import { Loader2, Mic, Send, Square, SquareStop } from 'lucide-react'
|
||||
import type { FormEvent, KeyboardEvent } from 'react'
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -8,8 +8,10 @@ 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 {
|
||||
@@ -28,6 +30,7 @@ interface ChatInputProps {
|
||||
selectedTabs: chrome.tabs.Tab[]
|
||||
onToggleTab: (tab: chrome.tabs.Tab) => void
|
||||
onTabMentionOpenChange?: (isOpen: boolean) => void
|
||||
voice?: VoiceInputState
|
||||
}
|
||||
|
||||
export interface ChatInputHandle {
|
||||
@@ -49,6 +52,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
selectedTabs,
|
||||
onToggleTab,
|
||||
onTabMentionOpenChange,
|
||||
voice,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -259,6 +263,78 @@ 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}
|
||||
@@ -273,38 +349,43 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
onClose={closeMention}
|
||||
anchorRef={textareaRef}
|
||||
/>
|
||||
<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>
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute right-1.5 bottom-1.5 flex items-center gap-1">
|
||||
{renderVoiceButton()}
|
||||
{renderSendButton()}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -63,8 +64,8 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
navigator.clipboard.writeText(messageText)
|
||||
track(SIDEPANEL_MESSAGE_COPIED_EVENT)
|
||||
}}
|
||||
label="Copy"
|
||||
tooltip="Copy to clipboard"
|
||||
label={i18n.t('chat.actions.copy')}
|
||||
tooltip={i18n.t('chat.actions.copyToClipboard')}
|
||||
>
|
||||
<CopyIcon className="size-3" />
|
||||
</MessageAction>
|
||||
@@ -79,7 +80,7 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
className="flex items-center gap-1 text-muted-foreground text-xs"
|
||||
>
|
||||
<CheckIcon className="size-3" />
|
||||
<span>Feedback submitted</span>
|
||||
<span>{i18n.t('chat.actions.feedbackSubmitted')}</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
@@ -91,9 +92,9 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<MessageAction
|
||||
label="Like"
|
||||
label={i18n.t('chat.actions.like')}
|
||||
onClick={handleLike}
|
||||
tooltip="Like this response"
|
||||
tooltip={i18n.t('chat.actions.likeTooltip')}
|
||||
>
|
||||
<ThumbsUpIcon
|
||||
className="size-4"
|
||||
@@ -101,9 +102,9 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
/>
|
||||
</MessageAction>
|
||||
<MessageAction
|
||||
label="Dislike"
|
||||
label={i18n.t('chat.actions.dislike')}
|
||||
onClick={handleDislikeClick}
|
||||
tooltip="Dislike this response"
|
||||
tooltip={i18n.t('chat.actions.dislikeTooltip')}
|
||||
>
|
||||
<ThumbsDownIcon
|
||||
className="size-4"
|
||||
@@ -117,13 +118,13 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
<Dialog open={dislikeDialogOpen} onOpenChange={setDislikeDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>What went wrong?</DialogTitle>
|
||||
<DialogTitle>{i18n.t('chat.actions.feedbackTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Help us improve by sharing what was wrong with this response.
|
||||
{i18n.t('chat.actions.feedbackDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="Add a comment (optional)"
|
||||
placeholder={i18n.t('chat.actions.feedbackPlaceholder')}
|
||||
value={dislikeComment}
|
||||
onChange={(e) => setDislikeComment(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -134,9 +135,11 @@ export const ChatMessageActions: FC<ChatMessageActionsProps> = ({
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleDislikeCancel}>
|
||||
Cancel
|
||||
{i18n.t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleDislikeSubmit}>
|
||||
{i18n.t('common.submit')}
|
||||
</Button>
|
||||
<Button onClick={handleDislikeSubmit}>Submit</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MessageSquare, MousePointer2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { i18n } from '#i18n'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -37,20 +38,20 @@ export const ChatModeToggle: FC<ChatModeToggleProps> = ({
|
||||
{isAgentMode ? (
|
||||
<>
|
||||
<MousePointer2 className="h-3 w-3" />
|
||||
<span>Agent Mode ON</span>
|
||||
<span>{i18n.t('chat.mode.agent')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
<span>Chat Mode ON</span>
|
||||
<span>{i18n.t('chat.mode.chat')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[220px]">
|
||||
{isAgentMode
|
||||
? 'AI can browse, click, and navigate'
|
||||
: 'AI can only read, cannot click or navigate'}
|
||||
? i18n.t('chat.mode.agentTooltip')
|
||||
: i18n.t('chat.mode.chatTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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,6 +1,7 @@
|
||||
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,
|
||||
@@ -38,7 +39,11 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
apiKeyUrl: string
|
||||
} | null>(null)
|
||||
const [resolvedText, setResolvedText] = useState(
|
||||
isLastMessage ? '' : `${(data.appName as string) ?? 'App'} suggested`,
|
||||
isLastMessage
|
||||
? ''
|
||||
: i18n.t('chat.connectApp.suggested', [
|
||||
(data.appName as string) ?? 'App',
|
||||
]),
|
||||
)
|
||||
|
||||
const { sendMessage } = useChatSessionContext()
|
||||
@@ -110,9 +115,11 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
managedServerDescription: '',
|
||||
})
|
||||
track(MANAGED_MCP_ADDED_EVENT, { server_name: appName })
|
||||
toast.success(`${apiKeyServer.name} connected successfully`)
|
||||
toast.success(
|
||||
i18n.t('chat.connectApp.connectedSuccess', [apiKeyServer.name]),
|
||||
)
|
||||
setApiKeyServer(null)
|
||||
setResolvedText(`Connected ${appName}`)
|
||||
setResolvedText(i18n.t('chat.connectApp.connected', [appName]))
|
||||
setPhase('resolved')
|
||||
sendMessage({
|
||||
text: `I've connected ${appName}, continue with the task`,
|
||||
@@ -127,7 +134,7 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
|
||||
const handleOAuthComplete = () => {
|
||||
track(BREADCRUMB_CONNECT_COMPLETED_EVENT, { app_name: appName })
|
||||
setResolvedText(`Connected ${appName}`)
|
||||
setResolvedText(i18n.t('chat.connectApp.connected', [appName]))
|
||||
setPhase('resolved')
|
||||
sendMessage({
|
||||
text: `I've connected ${appName}, continue with the task`,
|
||||
@@ -140,7 +147,7 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
if (!current.includes(appName)) {
|
||||
await declinedAppsStorage.setValue([...current, appName])
|
||||
}
|
||||
setResolvedText(`Continuing without ${appName}`)
|
||||
setResolvedText(i18n.t('chat.connectApp.continuedWithout', [appName]))
|
||||
setPhase('resolved')
|
||||
sendMessage({
|
||||
text: `Continue without connecting ${appName}, do it manually with browser automation`,
|
||||
@@ -165,20 +172,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">
|
||||
Authorize {appName} in the opened tab
|
||||
{i18n.t('chat.connectApp.authorizeTitle', [appName])}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
Complete the sign-in flow, then click the button below.
|
||||
{i18n.t('chat.connectApp.authorizeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={handleOAuthComplete}>
|
||||
I've authorized {appName}, continue
|
||||
{i18n.t('chat.connectApp.authorizedButton', [appName])}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleManual}>
|
||||
Skip, do it manually
|
||||
{i18n.t('chat.connectApp.skipButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +199,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">
|
||||
Connect {appName} for better results
|
||||
{i18n.t('chat.connectApp.connectForBetter', [appName])}
|
||||
</p>
|
||||
{reason && (
|
||||
<p className="mt-1 text-muted-foreground text-xs">{reason}</p>
|
||||
@@ -202,10 +209,12 @@ export const ConnectAppCard: FC<ConnectAppCardProps> = ({
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={handleConnect} disabled={connecting}>
|
||||
{connecting ? 'Connecting...' : `Connect ${appName}`}
|
||||
{connecting
|
||||
? i18n.t('chat.connectApp.connecting')
|
||||
: i18n.t('chat.connectApp.connectButton', [appName])}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleManual}>
|
||||
Do it manually
|
||||
{i18n.t('chat.connectApp.doItManually')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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,
|
||||
@@ -40,7 +41,9 @@ export const ScheduleSuggestionCard: FC<ScheduleSuggestionCardProps> = ({
|
||||
if (dismissed) return null
|
||||
|
||||
const scheduleLabel =
|
||||
scheduleType === 'daily' ? `daily at ${scheduleTime}` : 'every hour'
|
||||
scheduleType === 'daily'
|
||||
? i18n.t('chat.schedule.dailyAt', [scheduleTime])
|
||||
: i18n.t('chat.schedule.everyHour')
|
||||
|
||||
const handleSchedule = () => {
|
||||
track(BREADCRUMB_SCHEDULE_CLICKED_EVENT, {
|
||||
@@ -75,7 +78,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">Run this automatically?</p>
|
||||
<p className="font-medium text-sm">{i18n.t('chat.schedule.title')}</p>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
“{suggestedName}” — I can run this {scheduleLabel}
|
||||
</p>
|
||||
@@ -84,10 +87,10 @@ export const ScheduleSuggestionCard: FC<ScheduleSuggestionCardProps> = ({
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={handleSchedule}>
|
||||
Schedule this task
|
||||
{i18n.t('chat.schedule.scheduleButton')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleDismiss}>
|
||||
Maybe later
|
||||
{i18n.t('chat.schedule.maybeLater')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ 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'
|
||||
@@ -70,6 +71,8 @@ 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.`
|
||||
@@ -163,8 +166,34 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
const modeRef = useRef<ChatMode>(mode)
|
||||
const textToActionRef = useRef<Map<string, ChatAction>>(textToAction)
|
||||
const workingDirRef = useRef<string | undefined>(undefined)
|
||||
const selectionMapRef = useRef<
|
||||
Record<string, { text: string; url: string; title: string }>
|
||||
>({})
|
||||
const pendingSelectionTabKeyRef = useRef<string | null>(null)
|
||||
const messagesRef = useRef<UIMessage[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const toRef = (
|
||||
map: Record<string, { text: string; pageUrl: string; pageTitle: string }>,
|
||||
) => {
|
||||
const result: Record<
|
||||
string,
|
||||
{ text: string; url: string; title: string }
|
||||
> = {}
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
result[k] = { text: v.text, url: v.pageUrl, title: v.pageTitle }
|
||||
}
|
||||
return result
|
||||
}
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
selectionMapRef.current = toRef(map)
|
||||
})
|
||||
const unwatchText = selectedTextStorage.watch((map) => {
|
||||
selectionMapRef.current = toRef(map)
|
||||
})
|
||||
return () => unwatchText()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
selectedWorkspaceStorage.getValue().then((folder) => {
|
||||
workingDirRef.current = folder?.path
|
||||
@@ -208,8 +237,12 @@ 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
|
||||
@@ -284,7 +317,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
: history.map((m) => `${m.role}: ${m.content}`).join('\n')
|
||||
: undefined
|
||||
|
||||
return {
|
||||
const result = {
|
||||
api: `${agentUrlRef.current}/chat`,
|
||||
body: {
|
||||
message,
|
||||
@@ -305,6 +338,9 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
secretAccessKey: provider?.secretAccessKey,
|
||||
region: provider?.region,
|
||||
sessionToken: provider?.sessionToken,
|
||||
// ChatGPT Pro (Codex)
|
||||
reasoningEffort: provider?.reasoningEffort,
|
||||
reasoningSummary: provider?.reasoningSummary,
|
||||
browserContext,
|
||||
userSystemPrompt:
|
||||
options?.origin === 'newtab'
|
||||
@@ -316,8 +352,21 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
supportsImages: provider?.supportsImages,
|
||||
previousConversation,
|
||||
declinedApps: declinedApps.length > 0 ? declinedApps : undefined,
|
||||
selectedText: activeTabSelection?.text,
|
||||
selectedTextSource: activeTabSelection
|
||||
? {
|
||||
url: activeTabSelection.url,
|
||||
title: activeTabSelection.title,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
// Track which tab's selection was sent so we can clear it on success
|
||||
pendingSelectionTabKeyRef.current =
|
||||
activeTabSelection && activeTab?.id ? String(activeTab.id) : null
|
||||
|
||||
return result
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -411,6 +460,19 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
|
||||
if (!justFinished) return
|
||||
|
||||
// Clear the selected text that was sent with this request
|
||||
const tabKey = pendingSelectionTabKeyRef.current
|
||||
if (tabKey) {
|
||||
pendingSelectionTabKeyRef.current = null
|
||||
delete selectionMapRef.current[tabKey]
|
||||
selectedTextStorage.getValue().then((map) => {
|
||||
if (map[tabKey]) {
|
||||
const { [tabKey]: _, ...rest } = map
|
||||
selectedTextStorage.setValue(rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const messagesToSave = messages.filter((m) => m.parts?.length > 0)
|
||||
if (messagesToSave.length === 0) return
|
||||
|
||||
@@ -421,12 +483,47 @@ 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) => {
|
||||
@@ -503,6 +600,7 @@ export const useChatSession = (options?: ChatSessionOptions) => {
|
||||
providers,
|
||||
selectedProvider,
|
||||
isLoading: isLoadingProviders || isLoadingAgentUrl,
|
||||
isSyncing: !isIntegrationsSynced,
|
||||
isRestoringConversation,
|
||||
agentUrlError,
|
||||
chatError,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, type FC, type ReactNode, useContext } from 'react'
|
||||
import { useSyncRemoteIntegrations } from '@/lib/mcp/useSyncRemoteIntegrations'
|
||||
import {
|
||||
type ChatSessionOptions,
|
||||
useChatSession,
|
||||
@@ -11,7 +12,11 @@ const ChatSessionContext = createContext<ChatSessionContextValue | null>(null)
|
||||
export const ChatSessionProvider: FC<
|
||||
{ children: ReactNode } & ChatSessionOptions
|
||||
> = ({ children, ...options }) => {
|
||||
const session = useChatSession(options)
|
||||
const { hasSynced } = useSyncRemoteIntegrations()
|
||||
const session = useChatSession({
|
||||
...options,
|
||||
isIntegrationsSynced: hasSynced,
|
||||
})
|
||||
return (
|
||||
<ChatSessionContext.Provider value={session}>
|
||||
{children}
|
||||
|
||||
@@ -45,6 +45,10 @@ 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',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +76,8 @@ 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[] {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
|
||||
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { useVoiceInput } from '@/lib/voice/useVoiceInput'
|
||||
import { createBrowserOSAction } from './types'
|
||||
|
||||
interface ChatActionsConfig {
|
||||
/** Analytics event names scoped to the origin */
|
||||
events: {
|
||||
modeChanged: string
|
||||
stopClicked: string
|
||||
suggestionClicked: string
|
||||
tabToggled: string
|
||||
tabRemoved: string
|
||||
aiTriggered: string
|
||||
voiceRecordingStarted: string
|
||||
voiceRecordingStopped: string
|
||||
voiceTranscriptionCompleted: string
|
||||
voiceError: string
|
||||
}
|
||||
/** Auto-attach current active tab on mount (sidepanel only) */
|
||||
autoAttachActiveTab?: boolean
|
||||
}
|
||||
|
||||
export function useChatActions(config: ChatActionsConfig) {
|
||||
const session = useChatSessionContext()
|
||||
const { mode, setMode, sendMessage, stop, messages } = session
|
||||
|
||||
const voice = useVoiceInput()
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Auto-attach current tab on mount (sidepanel)
|
||||
useEffect(() => {
|
||||
if (!config.autoAttachActiveTab) return
|
||||
;(async () => {
|
||||
const currentTab = (
|
||||
await chrome.tabs.query({ active: true, currentWindow: true })
|
||||
).filter((tab) => tab.url?.startsWith('http'))
|
||||
setAttachedTabs(currentTab)
|
||||
})()
|
||||
}, [config.autoAttachActiveTab])
|
||||
|
||||
// Voice transcript → input
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only trigger on transcript/transcribing change
|
||||
useEffect(() => {
|
||||
if (voice.transcript && !voice.isTranscribing) {
|
||||
setInput((prev) => {
|
||||
const separator = prev.trim() ? ' ' : ''
|
||||
return prev + separator + voice.transcript
|
||||
})
|
||||
track(config.events.voiceTranscriptionCompleted)
|
||||
voice.clearTranscript()
|
||||
}
|
||||
}, [voice.transcript, voice.isTranscribing])
|
||||
|
||||
// Track voice errors
|
||||
useEffect(() => {
|
||||
if (voice.error) {
|
||||
track(config.events.voiceError, { error: voice.error })
|
||||
}
|
||||
}, [voice.error, config.events.voiceError])
|
||||
|
||||
const handleModeChange = (newMode: ChatMode) => {
|
||||
track(config.events.modeChanged, { from: mode, to: newMode })
|
||||
setMode(newMode)
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
track(config.events.stopClicked)
|
||||
stop()
|
||||
}
|
||||
|
||||
const toggleTabSelection = (tab: chrome.tabs.Tab) => {
|
||||
setAttachedTabs((prev) => {
|
||||
const isSelected = prev.some((t) => t.id === tab.id)
|
||||
track(config.events.tabToggled, {
|
||||
action: isSelected ? 'removed' : 'added',
|
||||
})
|
||||
if (isSelected) {
|
||||
return prev.filter((t) => t.id !== tab.id)
|
||||
}
|
||||
return [...prev, tab]
|
||||
})
|
||||
}
|
||||
|
||||
const removeTab = (tabId?: number) => {
|
||||
track(config.events.tabRemoved)
|
||||
setAttachedTabs((prev) => prev.filter((t) => t.id !== tabId))
|
||||
}
|
||||
|
||||
const executeMessage = (customMessageText?: string) => {
|
||||
const messageText = customMessageText ? customMessageText : input.trim()
|
||||
if (!messageText) return
|
||||
|
||||
if (attachedTabs.length) {
|
||||
const action = createBrowserOSAction({
|
||||
mode,
|
||||
message: messageText,
|
||||
tabs: attachedTabs,
|
||||
})
|
||||
sendMessage({ text: messageText, action })
|
||||
} else {
|
||||
sendMessage({ text: messageText })
|
||||
}
|
||||
setInput('')
|
||||
setAttachedTabs([])
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (messages.length === 0) {
|
||||
track(config.events.aiTriggered, {
|
||||
mode,
|
||||
tabs_count: attachedTabs.length,
|
||||
})
|
||||
}
|
||||
executeMessage()
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
track(config.events.suggestionClicked, { mode })
|
||||
executeMessage(suggestion)
|
||||
}
|
||||
|
||||
const handleStartRecording = async () => {
|
||||
const started = await voice.startRecording()
|
||||
if (started) {
|
||||
track(config.events.voiceRecordingStarted)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRecording = async () => {
|
||||
await voice.stopRecording()
|
||||
track(config.events.voiceRecordingStopped)
|
||||
}
|
||||
|
||||
const voiceState = {
|
||||
isRecording: voice.isRecording,
|
||||
isTranscribing: voice.isTranscribing,
|
||||
audioLevels: voice.audioLevels,
|
||||
error: voice.error,
|
||||
onStartRecording: handleStartRecording,
|
||||
onStopRecording: handleStopRecording,
|
||||
}
|
||||
|
||||
const { stop: _stop, ...restSession } = session
|
||||
|
||||
return {
|
||||
...restSession,
|
||||
input,
|
||||
setInput,
|
||||
attachedTabs,
|
||||
setAttachedTabs,
|
||||
mounted,
|
||||
voiceState,
|
||||
handleModeChange,
|
||||
handleStop,
|
||||
toggleTabSelection,
|
||||
removeTab,
|
||||
executeMessage,
|
||||
handleSubmit,
|
||||
handleSuggestionClick,
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,30 @@ 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'
|
||||
|
||||
@@ -56,6 +80,10 @@ export const SCHEDULED_TASK_DELETED_EVENT = 'settings.scheduled_task.deleted'
|
||||
/** @public */
|
||||
export const SCHEDULED_TASK_TOGGLED_EVENT = 'settings.scheduled_task.toggled'
|
||||
|
||||
/** @public */
|
||||
export const SCHEDULED_TASK_PROMPT_REFINED_EVENT =
|
||||
'settings.scheduled_task.prompt_refined'
|
||||
|
||||
/** @public */
|
||||
export const SCHEDULED_TASK_TESTED_EVENT = 'settings.scheduled_task.tested'
|
||||
|
||||
@@ -114,6 +142,21 @@ export const NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT =
|
||||
/** @public */
|
||||
export const NEWTAB_CHAT_MODE_CHANGED_EVENT = 'newtab.chat.mode_changed'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_VOICE_RECORDING_STARTED_EVENT =
|
||||
'newtab.voice.recording_started'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_VOICE_RECORDING_STOPPED_EVENT =
|
||||
'newtab.voice.recording_stopped'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_VOICE_TRANSCRIPTION_COMPLETED_EVENT =
|
||||
'newtab.voice.transcription_completed'
|
||||
|
||||
/** @public */
|
||||
export const NEWTAB_VOICE_ERROR_EVENT = 'newtab.voice.error'
|
||||
|
||||
/** @public */
|
||||
export const WORKFLOW_DELETED_EVENT = 'settings.workflow.deleted'
|
||||
|
||||
@@ -251,3 +294,18 @@ 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 } from 'lucide-react'
|
||||
import { Bot, Github } from 'lucide-react'
|
||||
import type { FC, SVGProps } from 'react'
|
||||
import ProductLogoSvg from '@/assets/product_logo.svg'
|
||||
import type { ProviderType } from './types'
|
||||
@@ -32,6 +32,8 @@ const providerIconMap: Record<ProviderType, IconComponent | null> = {
|
||||
bedrock: Bedrock,
|
||||
browseros: null,
|
||||
moonshot: Kimi,
|
||||
'chatgpt-pro': OpenAI,
|
||||
'github-copilot': Github,
|
||||
}
|
||||
|
||||
interface ProviderIconProps {
|
||||
|
||||
@@ -20,6 +20,24 @@ 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',
|
||||
@@ -129,6 +147,8 @@ 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' },
|
||||
@@ -157,6 +177,8 @@ 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,6 +14,8 @@ export type ProviderType =
|
||||
| 'bedrock'
|
||||
| 'browseros'
|
||||
| 'moonshot'
|
||||
| 'chatgpt-pro'
|
||||
| 'github-copilot'
|
||||
|
||||
/**
|
||||
* LLM Provider configuration
|
||||
@@ -56,6 +58,10 @@ export interface LlmProviderConfig {
|
||||
region?: string
|
||||
/** AWS session token (for temporary STS credentials) */
|
||||
sessionToken?: string
|
||||
|
||||
// ChatGPT Pro (Codex) fields
|
||||
reasoningEffort?: 'none' | 'low' | 'medium' | 'high'
|
||||
reasoningSummary?: 'auto' | 'concise' | 'detailed'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -158,9 +158,7 @@ export function useLlmProviders(): UseLlmProvidersReturn {
|
||||
// Fall back to first provider if defaultProviderId is stale/invalid
|
||||
const selectedProvider = useMemo(
|
||||
() =>
|
||||
providers.find((p) => p.id === defaultProviderId) ??
|
||||
providers[0] ??
|
||||
null,
|
||||
providers.find((p) => p.id === defaultProviderId) ?? providers[0] ?? null,
|
||||
[providers, defaultProviderId],
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
interface OAuthStatus {
|
||||
authenticated: boolean
|
||||
email?: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface UseOAuthStatusReturn {
|
||||
status: OAuthStatus | null
|
||||
isPolling: boolean
|
||||
startPolling: () => void
|
||||
stopPolling: () => void
|
||||
refresh: () => Promise<OAuthStatus | null>
|
||||
disconnect: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useOAuthStatus(provider: string): UseOAuthStatusReturn {
|
||||
const [status, setStatus] = useState<OAuthStatus | null>(null)
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
async function fetchStatus(): Promise<OAuthStatus | null> {
|
||||
try {
|
||||
const serverUrl = await getAgentServerUrl()
|
||||
const res = await fetch(`${serverUrl}/oauth/${provider}/status`)
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as OAuthStatus
|
||||
setStatus(data)
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current)
|
||||
if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current)
|
||||
pollIntervalRef.current = null
|
||||
pollTimeoutRef.current = null
|
||||
setIsPolling(false)
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
setIsPolling(true)
|
||||
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
const result = await fetchStatus()
|
||||
if (result?.authenticated) {
|
||||
stopPolling()
|
||||
}
|
||||
}, 2_000)
|
||||
|
||||
pollTimeoutRef.current = setTimeout(stopPolling, 300_000)
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
try {
|
||||
const serverUrl = await getAgentServerUrl()
|
||||
await fetch(`${serverUrl}/oauth/${provider}`, { method: 'DELETE' })
|
||||
setStatus({ authenticated: false, provider })
|
||||
} catch {
|
||||
// Best-effort disconnect
|
||||
}
|
||||
}
|
||||
|
||||
// Initial status check on mount
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup only needs to run on unmount
|
||||
useEffect(() => {
|
||||
return () => stopPolling()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
status,
|
||||
isPolling,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
refresh: fetchStatus,
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } 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.
|
||||
*
|
||||
@@ -12,8 +19,10 @@ import { type McpServer, mcpServerStorage } from './mcpServerStorage'
|
||||
*
|
||||
* 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() {
|
||||
export function useSyncRemoteIntegrations(): SyncStatus {
|
||||
const { data: userMCPIntegrations, isLoading: isIntegrationsLoading } =
|
||||
useGetUserMCPIntegrations()
|
||||
const { data: serversList } = useGetMCPServersList()
|
||||
@@ -21,13 +30,26 @@ export function useSyncRemoteIntegrations() {
|
||||
const serversListRef = useRef(serversList)
|
||||
integrationsRef.current = userMCPIntegrations
|
||||
serversListRef.current = serversList
|
||||
const hasSynced = useRef(false)
|
||||
const hasSyncedRef = useRef(false)
|
||||
const [syncState, setSyncState] = useState<SyncStatus>({
|
||||
isSyncing: true,
|
||||
hasSynced: false,
|
||||
})
|
||||
|
||||
const integrationCount = userMCPIntegrations?.integrations?.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
if (isIntegrationsLoading || !integrationCount) return
|
||||
if (hasSynced.current) return
|
||||
// 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
|
||||
|
||||
const integrations = integrationsRef.current?.integrations
|
||||
if (!integrations) return
|
||||
@@ -40,26 +62,30 @@ export function useSyncRemoteIntegrations() {
|
||||
!localServers.some((s) => s.managedServerName === remote.name),
|
||||
)
|
||||
|
||||
if (missing.length === 0) return
|
||||
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 ?? '',
|
||||
}
|
||||
})
|
||||
|
||||
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 ?? '',
|
||||
}
|
||||
})
|
||||
await mcpServerStorage.setValue([...localServers, ...newServers])
|
||||
}
|
||||
|
||||
await mcpServerStorage.setValue([...localServers, ...newServers])
|
||||
hasSyncedRef.current = true
|
||||
setSyncState({ isSyncing: false, hasSynced: true })
|
||||
}
|
||||
|
||||
hasSynced.current = true
|
||||
syncMissing()
|
||||
}, [isIntegrationsLoading, integrationCount])
|
||||
|
||||
return syncState
|
||||
}
|
||||
|
||||
@@ -23,4 +23,4 @@ type ScheduleMessagesProtocol = {
|
||||
const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<ScheduleMessagesProtocol>()
|
||||
|
||||
export { sendMessage as sendScheduleMessage, onMessage as onScheduleMessage }
|
||||
export { onMessage as onScheduleMessage, sendMessage as sendScheduleMessage }
|
||||
|
||||
@@ -12,4 +12,4 @@ type ServerMessagesProtocol = {
|
||||
const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<ServerMessagesProtocol>()
|
||||
|
||||
export { sendMessage as sendServerMessage, onMessage as onServerMessage }
|
||||
export { onMessage as onServerMessage, sendMessage as sendServerMessage }
|
||||
|
||||
@@ -12,6 +12,6 @@ const { sendMessage, onMessage } =
|
||||
defineExtensionMessaging<OpenSidePanelWithSearchParams>()
|
||||
|
||||
export {
|
||||
sendMessage as openSidePanelWithSearch,
|
||||
onMessage as onOpenSidePanelWithSearch,
|
||||
sendMessage as openSidePanelWithSearch,
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ChatServerRequest {
|
||||
windowId?: number
|
||||
activeTab?: ActiveTab
|
||||
signal?: AbortSignal
|
||||
providerId?: string
|
||||
}
|
||||
|
||||
interface ChatServerResponse {
|
||||
@@ -75,11 +76,23 @@ const getDefaultProvider = async (): Promise<LlmProviderConfig | null> => {
|
||||
return defaultProvider ?? providers[0] ?? null
|
||||
}
|
||||
|
||||
// Resolve provider by ID, falling back to global default
|
||||
const resolveProvider = async (
|
||||
providerId?: string,
|
||||
): Promise<LlmProviderConfig> => {
|
||||
if (providerId) {
|
||||
const providers = await providersStorage.getValue()
|
||||
const match = providers?.find((p) => p.id === providerId)
|
||||
if (match) return match
|
||||
}
|
||||
return (await getDefaultProvider()) ?? createDefaultBrowserOSProvider()
|
||||
}
|
||||
|
||||
export async function getChatServerResponse(
|
||||
request: ChatServerRequest,
|
||||
): Promise<ChatServerResponse> {
|
||||
const agentServerUrl = await getAgentServerUrl()
|
||||
const provider = (await getDefaultProvider()) ?? createDefaultBrowserOSProvider()
|
||||
const provider = await resolveProvider(request.providerId)
|
||||
const conversationId = request.conversationId ?? crypto.randomUUID()
|
||||
const personalization = await personalizationStorage.getValue()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export const GetScheduledJobsByProfileIdDocument = graphql(`
|
||||
scheduleTime
|
||||
scheduleInterval
|
||||
enabled
|
||||
llmProviderId
|
||||
createdAt
|
||||
updatedAt
|
||||
lastRunAt
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import {
|
||||
createDefaultBrowserOSProvider,
|
||||
defaultProviderIdStorage,
|
||||
providersStorage,
|
||||
} from '@/lib/llm-providers/storage'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
|
||||
const resolveProvider = async (
|
||||
providerId?: string,
|
||||
): Promise<LlmProviderConfig> => {
|
||||
const providers = await providersStorage.getValue()
|
||||
if (providerId && providers?.length) {
|
||||
const match = providers.find((p) => p.id === providerId)
|
||||
if (match) return match
|
||||
}
|
||||
if (providers?.length) {
|
||||
const defaultProviderId = await defaultProviderIdStorage.getValue()
|
||||
const defaultProvider = providers.find((p) => p.id === defaultProviderId)
|
||||
if (defaultProvider) return defaultProvider
|
||||
if (providers[0]) return providers[0]
|
||||
}
|
||||
return createDefaultBrowserOSProvider()
|
||||
}
|
||||
|
||||
interface RefinePromptResponse {
|
||||
success: boolean
|
||||
refined?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function refinePrompt(params: {
|
||||
prompt: string
|
||||
name: string
|
||||
providerId?: string
|
||||
}): Promise<string> {
|
||||
const agentServerUrl = await getAgentServerUrl()
|
||||
const provider = await resolveProvider(params.providerId)
|
||||
|
||||
const response = await fetch(`${agentServerUrl}/refine-prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: params.prompt,
|
||||
name: params.name,
|
||||
provider: provider.type,
|
||||
model: provider.modelId ?? 'default',
|
||||
apiKey: provider.apiKey,
|
||||
baseUrl: provider.baseUrl,
|
||||
resourceName: provider.resourceName,
|
||||
accessKeyId: provider.accessKeyId,
|
||||
secretAccessKey: provider.secretAccessKey,
|
||||
region: provider.region,
|
||||
sessionToken: provider.sessionToken,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response
|
||||
.json()
|
||||
.catch(() => null)) as RefinePromptResponse | null
|
||||
throw new Error(errorData?.message ?? `Request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as RefinePromptResponse
|
||||
if (!data.success || !data.refined) {
|
||||
throw new Error(data.message ?? 'Failed to refine prompt')
|
||||
}
|
||||
|
||||
return data.refined
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface ScheduledJob {
|
||||
scheduleTime?: string
|
||||
scheduleInterval?: number
|
||||
enabled: boolean
|
||||
providerId?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastRunAt?: string
|
||||
|
||||
@@ -19,6 +19,7 @@ type RemoteScheduledJob = {
|
||||
scheduleTime: string | null
|
||||
scheduleInterval: number | null
|
||||
enabled: boolean
|
||||
llmProviderId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastRunAt: string | null
|
||||
@@ -32,6 +33,7 @@ function toComparable(job: ScheduledJob) {
|
||||
...data,
|
||||
scheduleTime: data.scheduleTime ?? null,
|
||||
scheduleInterval: data.scheduleInterval ?? null,
|
||||
providerId: data.providerId ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +45,7 @@ function remoteToComparable(job: RemoteScheduledJob) {
|
||||
scheduleTime: job.scheduleTime,
|
||||
scheduleInterval: job.scheduleInterval,
|
||||
enabled: job.enabled,
|
||||
providerId: job.llmProviderId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +62,7 @@ function remoteToLocal(remote: RemoteScheduledJob): ScheduledJob {
|
||||
scheduleTime: remote.scheduleTime ?? undefined,
|
||||
scheduleInterval: remote.scheduleInterval ?? undefined,
|
||||
enabled: remote.enabled,
|
||||
providerId: remote.llmProviderId ?? undefined,
|
||||
createdAt: normalizeTimestamp(remote.createdAt),
|
||||
updatedAt: normalizeTimestamp(remote.updatedAt),
|
||||
lastRunAt: remote.lastRunAt
|
||||
@@ -163,6 +167,7 @@ export async function syncSchedulesToBackend(
|
||||
scheduleTime: job.scheduleTime ?? null,
|
||||
scheduleInterval: job.scheduleInterval ?? null,
|
||||
enabled: job.enabled,
|
||||
llmProviderId: job.providerId ?? null,
|
||||
lastRunAt: job.lastRunAt
|
||||
? new Date(job.lastRunAt).toISOString()
|
||||
: null,
|
||||
@@ -182,6 +187,7 @@ export async function syncSchedulesToBackend(
|
||||
scheduleTime: job.scheduleTime ?? null,
|
||||
scheduleInterval: job.scheduleInterval ?? null,
|
||||
enabled: job.enabled,
|
||||
llmProviderId: job.providerId ?? null,
|
||||
createdAt: new Date(job.createdAt).toISOString(),
|
||||
updatedAt: job.updatedAt || new Date().toISOString(),
|
||||
lastRunAt: job.lastRunAt
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
|
||||
export interface SelectedTextData {
|
||||
text: string
|
||||
pageUrl: string
|
||||
pageTitle: string
|
||||
tabId: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/** Map of tabId → selected text. Each tab's selection is independent. */
|
||||
export const selectedTextStorage = storage.defineItem<
|
||||
Record<string, SelectedTextData>
|
||||
>('local:selectedTextMap', { defaultValue: {} })
|
||||
@@ -1,18 +1,35 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const GATEWAY_URL = 'https://llm.browseros.com'
|
||||
const WAVEFORM_BAND_COUNT = 5
|
||||
|
||||
interface UseVoiceInputReturn {
|
||||
export interface VoiceInputState {
|
||||
isRecording: boolean
|
||||
isTranscribing: boolean
|
||||
audioLevels: number[]
|
||||
error: string | null
|
||||
onStartRecording: () => void
|
||||
onStopRecording: () => void
|
||||
}
|
||||
|
||||
export interface UseVoiceInputReturn {
|
||||
isRecording: boolean
|
||||
isTranscribing: boolean
|
||||
transcript: string
|
||||
audioLevel: number
|
||||
audioLevels: number[]
|
||||
error: string | null
|
||||
startRecording: () => Promise<void>
|
||||
startRecording: () => Promise<boolean>
|
||||
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')
|
||||
@@ -21,16 +38,19 @@ 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 error = await response
|
||||
const errorBody: { error?: string } = await response
|
||||
.json()
|
||||
.catch(() => ({ error: 'Transcription failed' }))
|
||||
throw new Error(error.error || `Transcription failed: ${response.status}`)
|
||||
throw new Error(
|
||||
errorBody.error || `Transcription failed: ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const result: TranscribeResponse = await response.json()
|
||||
return result.text || ''
|
||||
}
|
||||
|
||||
@@ -39,6 +59,7 @@ 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)
|
||||
@@ -48,7 +69,7 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
const analyserRef = useRef<AnalyserNode | null>(null)
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
|
||||
const stopAudioLevelMonitoring = useCallback(() => {
|
||||
const stopAudioLevelMonitoring = () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
@@ -59,8 +80,10 @@ 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) => {
|
||||
@@ -71,9 +94,9 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
}
|
||||
stopAudioLevelMonitoring()
|
||||
}
|
||||
}, [stopAudioLevelMonitoring])
|
||||
}, [])
|
||||
|
||||
const startAudioLevelMonitoring = useCallback((stream: MediaStream) => {
|
||||
const startAudioLevelMonitoring = (stream: MediaStream) => {
|
||||
const audioContext = new AudioContext()
|
||||
const analyser = audioContext.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
@@ -87,20 +110,36 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
const updateLevel = () => {
|
||||
if (!analyserRef.current) return
|
||||
|
||||
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount)
|
||||
analyserRef.current.getByteFrequencyData(dataArray)
|
||||
const dataArray = new Uint8Array(analyserRef.current.fftSize)
|
||||
analyserRef.current.getByteTimeDomainData(dataArray)
|
||||
|
||||
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length
|
||||
const normalized = Math.min(100, (average / 128) * 100)
|
||||
setAudioLevel(Math.round(normalized))
|
||||
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))
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(updateLevel)
|
||||
}
|
||||
|
||||
updateLevel()
|
||||
}, [])
|
||||
}
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
const startRecording = async (): Promise<boolean> => {
|
||||
try {
|
||||
setError(null)
|
||||
setTranscript('')
|
||||
@@ -133,7 +172,14 @@ 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')
|
||||
@@ -145,10 +191,11 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
} else {
|
||||
setError('Failed to start recording')
|
||||
}
|
||||
return false
|
||||
}
|
||||
}, [startAudioLevelMonitoring])
|
||||
}
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
const stopRecording = async () => {
|
||||
const mediaRecorder = mediaRecorderRef.current
|
||||
|
||||
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
|
||||
@@ -188,18 +235,19 @@ export function useVoiceInput(): UseVoiceInputReturn {
|
||||
} finally {
|
||||
setIsTranscribing(false)
|
||||
}
|
||||
}, [stopAudioLevelMonitoring])
|
||||
}
|
||||
|
||||
const clearTranscript = useCallback(() => {
|
||||
const clearTranscript = () => {
|
||||
setTranscript('')
|
||||
setError(null)
|
||||
}, [])
|
||||
}
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
isTranscribing,
|
||||
transcript,
|
||||
audioLevel,
|
||||
audioLevels,
|
||||
error,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
134
packages/browseros-agent/apps/agent/locales/de.yml
Normal file
134
packages/browseros-agent/apps/agent/locales/de.yml
Normal file
@@ -0,0 +1,134 @@
|
||||
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
|
||||
169
packages/browseros-agent/apps/agent/locales/en.yml
Normal file
169
packages/browseros-agent/apps/agent/locales/en.yml
Normal file
@@ -0,0 +1,169 @@
|
||||
# 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
|
||||
134
packages/browseros-agent/apps/agent/locales/es.yml
Normal file
134
packages/browseros-agent/apps/agent/locales/es.yml
Normal file
@@ -0,0 +1,134 @@
|
||||
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
|
||||
134
packages/browseros-agent/apps/agent/locales/fr.yml
Normal file
134
packages/browseros-agent/apps/agent/locales/fr.yml
Normal file
@@ -0,0 +1,134 @@
|
||||
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
|
||||
134
packages/browseros-agent/apps/agent/locales/ja.yml
Normal file
134
packages/browseros-agent/apps/agent/locales/ja.yml
Normal file
@@ -0,0 +1,134 @@
|
||||
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: タブ
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user