Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.1] - 2026-06-12

### Added

- 🌐 **Browser tools.** Browse the web from chat. Navigate pages, click elements, type into forms, take screenshots, and run JavaScript. Works with local Chrome (auto-launched), Firecrawl, or Browser-Use. Enable in Settings > Browser.
- 🖼️ **Image understanding.** The AI can now read and describe images from your workspace. Open a screenshot or image file and it just works, across all providers.
- 🔴 **Error toasts.** When something goes wrong during a response (API errors, model failures), you'll now see a clear error message in the chat and a toast notification instead of silent failures.

### Fixed

- 🔁 **Responses API multi-turn tool calling.** Fixed an issue where tool calls would stop after the first round when using OpenAI's Responses API. The AI now correctly loops through multiple tool calls as expected.
- 💬 **`/new` command in Telegram/Discord.** Starting a new conversation with `/new` now actually creates a fresh chat instead of continuing the previous one.
- 🛡️ **Responses API spec compliance.** Input messages, tool outputs, and error handling now fully follow the Open Responses specification, preventing unexpected 400 errors.

## [0.3.0] - 2026-06-12

### Added
Expand Down
9 changes: 9 additions & 0 deletions cptr/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ async def shutdown():
bot_manager = getattr(app.state, "bot_manager", None)
if bot_manager:
await bot_manager.stop_all()
# Clean up browser sessions and launched Chrome
try:
from cptr.utils.browser.session import session_manager
from cptr.utils.browser.launcher import shutdown_browser

await session_manager.close_all()
await shutdown_browser()
except Exception:
pass


# Auth middleware
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.3.0",
"version": "0.3.1",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down
4 changes: 2 additions & 2 deletions cptr/frontend/src/lib/apis/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ export const getAdminConfig = async (): Promise<Record<string, unknown>> => {

export const updateConfig = (config: Record<string, unknown>) =>
fetchJSON('/api/admin/config', {
method: 'PUT',
...jsonBody({ config })
...jsonBody({ config }),
method: 'PUT'
});

// ── Connections ─────────────────────────────────────────────
Expand Down
5 changes: 5 additions & 0 deletions cptr/frontend/src/lib/components/Icon.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -333,5 +333,10 @@
{:else if name === 'signal'}
<path d="M8.5 11.5L11.5 14.5L16.5 9.5" />
<path d="M5 18L3.13036 4.91253C3.05646 4.39524 3.39389 3.91247 3.90398 3.79912L11.5661 2.09641C11.8519 2.03291 12.1481 2.03291 12.4339 2.09641L20.096 3.79912C20.6061 3.91247 20.9435 4.39524 20.8696 4.91252L19 18C18.9293 18.495 18.5 21.5 12 21.5C5.5 21.5 5.07071 18.495 5 18Z" />
{:else if name === 'browser'}
<path d="M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12Z" />
<path d="M13 2.04932C13 2.04932 16 6 16 12C16 18 13 21.9507 13 21.9507" />
<path d="M11 21.9507C11 21.9507 8 18 8 12C8 6 11 2.04932 11 2.04932" />
<path d="M2 12H22" />
{/if}
</svg>
254 changes: 254 additions & 0 deletions cptr/frontend/src/lib/components/Settings/Browser.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import ToggleSwitch from '../common/ToggleSwitch.svelte';
import Spinner from '../common/Spinner.svelte';
import { onMount } from 'svelte';
import { getAdminConfig, updateConfig } from '$lib/apis/admin';
import { t } from '$lib/i18n';

let loading = $state(true);
let saving = $state(false);
let testing = $state(false);
let testResult = $state<{ ok: boolean; message: string } | null>(null);

// Config state
let enabled = $state(false);
let provider = $state<'local' | 'firecrawl' | 'browser_use'>('local');
let cdpUrl = $state('http://localhost:9222');
let autoLaunch = $state(true);
let sessionTimeout = $state(10);
let firecrawlApiKey = $state('');
let firecrawlBaseUrl = $state('https://api.firecrawl.dev');
let browserUseApiKey = $state('');
let browserUseBaseUrl = $state('https://api.browser-use.com');

onMount(async () => {
try {
const config = await getAdminConfig();
enabled = config['browser.enabled'] === true || config['browser.enabled'] === 'true';
provider = (config['browser.provider'] as typeof provider) || 'local';
cdpUrl = (config['browser.cdp_url'] as string) || 'http://localhost:9222';
autoLaunch = config['browser.auto_launch'] !== false && config['browser.auto_launch'] !== 'false';
sessionTimeout = Number(config['browser.session_timeout_minutes']) || 10;
firecrawlApiKey = (config['browser.firecrawl_api_key'] as string) || '';
firecrawlBaseUrl = (config['browser.firecrawl_base_url'] as string) || 'https://api.firecrawl.dev';
browserUseApiKey = (config['browser.browser_use_api_key'] as string) || '';
browserUseBaseUrl = (config['browser.browser_use_base_url'] as string) || 'https://api.browser-use.com';
} catch {}
loading = false;
});

async function save() {
saving = true;
try {
await updateConfig({
'browser.enabled': enabled,
'browser.provider': provider,
'browser.cdp_url': cdpUrl,
'browser.auto_launch': autoLaunch,
'browser.session_timeout_minutes': sessionTimeout,
'browser.firecrawl_api_key': firecrawlApiKey,
'browser.firecrawl_base_url': firecrawlBaseUrl,
'browser.browser_use_api_key': browserUseApiKey,
'browser.browser_use_base_url': browserUseBaseUrl
});
toast.success($t('settings.saved'));
} catch {
toast.error('Failed to save browser settings');
} finally {
saving = false;
}
}

async function testConnection() {
testing = true;
testResult = null;
try {
const resp = await fetch(`${cdpUrl}/json/version`);
if (resp.ok) {
const data = await resp.json();
testResult = { ok: true, message: data.Browser || 'Connected' };
} else {
testResult = { ok: false, message: `HTTP ${resp.status}` };
}
} catch {
testResult = { ok: false, message: 'Could not connect' };
} finally {
testing = false;
}
}
</script>

<div class="flex flex-col min-h-full">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">Browser</h2>

{#if loading}
<div class="flex justify-center py-8"><Spinner size={16} /></div>
{:else}
<!-- Enable -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">Enable</h3>

<div class="flex flex-col gap-2.5">
<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">Browser tools</span>
<ToggleSwitch value={enabled} onchange={(v) => { enabled = v; }} />
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
Give the AI access to a web browser for navigating pages, clicking elements, and taking screenshots.
</p>
</div>

{#if enabled}
<!-- Provider -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Provider</h3>

<div class="flex gap-1">
{#each [
{ value: 'local' as const, label: 'Local CDP' },
{ value: 'firecrawl' as const, label: 'Firecrawl' },
{ value: 'browser_use' as const, label: 'Browser-Use' }
] as opt}
<button
class="flex items-center gap-1.5 h-7 px-2.5 rounded-lg text-xs transition-colors duration-100
{provider === opt.value
? 'bg-gray-200/50 dark:bg-white/8 text-gray-900 dark:text-white font-medium'
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}"
onclick={() => { provider = opt.value; }}
>
{opt.label}
</button>
{/each}
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-1">
{#if provider === 'local'}
Connects to Chrome via DevTools Protocol. Full interactive browsing with clicking, typing, and screenshots.
{:else if provider === 'firecrawl'}
Cloud API that converts web pages to markdown. Fast extraction, no interactive browsing.
{:else}
Cloud API for LLM-driven browser tasks. Describe what you need in natural language.
{/if}
</p>

<!-- Local CDP settings -->
{#if provider === 'local'}
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Connection</h3>

<div class="flex flex-col gap-2.5">
<label class="flex items-center justify-between cursor-pointer">
<div>
<span class="text-xs text-gray-600 dark:text-gray-400">Auto-launch Chrome</span>
<p class="text-[10px] text-gray-400 dark:text-gray-600">Start a headless Chrome if none is running</p>
</div>
<ToggleSwitch value={autoLaunch} onchange={(v) => { autoLaunch = v; }} />
</label>

<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="cdp-url">CDP URL</label>
<div class="flex gap-1.5 mt-1">
<input
id="cdp-url"
type="text"
bind:value={cdpUrl}
placeholder="http://localhost:9222"
class="flex-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
<button
class="h-7 px-2.5 rounded-lg text-xs bg-gray-200/50 dark:bg-white/8 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors disabled:opacity-50"
onclick={() => testConnection()}
disabled={testing}
>
{testing ? '...' : 'Test'}
</button>
</div>
{#if testResult}
<p class="text-[11px] mt-1 {testResult.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'}">
{testResult.message}
</p>
{/if}
</div>

<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="session-timeout">Session timeout</label>
<div class="flex items-center gap-1.5 mt-1">
<input
id="session-timeout"
type="number"
bind:value={sessionTimeout}
min="1"
max="120"
class="w-16 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
<span class="text-[11px] text-gray-400 dark:text-gray-600">minutes</span>
</div>
</div>
</div>
{/if}

<!-- Firecrawl settings -->
{#if provider === 'firecrawl'}
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Firecrawl</h3>

<div class="flex flex-col gap-2.5">
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="fc-key">API Key</label>
<input
id="fc-key"
type="password"
bind:value={firecrawlApiKey}
placeholder="fc-..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="fc-url">Base URL</label>
<input
id="fc-url"
type="text"
bind:value={firecrawlBaseUrl}
placeholder="https://api.firecrawl.dev"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-1">Change for self-hosted Firecrawl instances</p>
</div>
</div>
{/if}

<!-- Browser-Use settings -->
{#if provider === 'browser_use'}
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Browser-Use</h3>

<div class="flex flex-col gap-2.5">
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="bu-key">API Key</label>
<input
id="bu-key"
type="password"
bind:value={browserUseApiKey}
placeholder="bu-..."
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="bu-url">Base URL</label>
<input
id="bu-url"
type="text"
bind:value={browserUseBaseUrl}
placeholder="https://api.browser-use.com"
class="w-full mt-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors"
/>
</div>
</div>
{/if}
{/if}

<!-- Save -->
<div class="mt-auto pt-6 flex justify-end">
<button
class="text-[13px] text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors duration-100 disabled:opacity-50"
onclick={() => save()}
disabled={saving}
>{$t('settings.save')}</button>
</div>
{/if}
</div>
5 changes: 5 additions & 0 deletions cptr/frontend/src/lib/components/SettingsModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import General from './Settings/General.svelte';
import Account from './Settings/Account.svelte';
import Keyboard from './Settings/Keyboard.svelte';
import Browser from './Settings/Browser.svelte';
import About from './Settings/About.svelte';
import Users from './Admin/Users.svelte';
import Connections from './Admin/Connections.svelte';
Expand All @@ -16,6 +17,7 @@
type Tab =
| 'general'
| 'keyboard'
| 'browser'
| 'account'
| 'about'
| 'users'
Expand Down Expand Up @@ -47,6 +49,7 @@
{ id: 'connections', label: $t('admin.connections'), icon: 'plug' },
{ id: 'models', label: $t('admin.models'), icon: 'cube' },
{ id: 'messaging', label: $t('admin.messaging'), icon: 'chat-bubble' },
{ id: 'browser', label: 'Browser', icon: 'browser' },
{ id: 'admin_settings', label: $t('settings.configuration'), icon: 'shield' }
]);
</script>
Expand Down Expand Up @@ -106,6 +109,8 @@
<General />
{:else if activeTab === 'keyboard'}
<Keyboard />
{:else if activeTab === 'browser'}
<Browser />
{:else if activeTab === 'account'}
<Account />
{:else if activeTab === 'about'}
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/src/lib/components/chat/ChatInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@
if (!child) return;
const popupHeight = child.offsetHeight || 200;
child.style.position = 'fixed';
child.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 340))}px`;
child.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 280))}px`;
child.style.top = `${rect.top - popupHeight - 8}px`;
}

Expand Down
5 changes: 5 additions & 0 deletions cptr/frontend/src/lib/components/chat/ChatPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import AssistantMessage from './AssistantMessage.svelte';
import ChatHistory from './ChatHistory.svelte';
import Spinner from '../common/Spinner.svelte';
import { toast } from 'svelte-sonner';

interface Props {
workspace: string;
Expand Down Expand Up @@ -307,6 +308,7 @@
delta?: string;
output?: any;
done?: boolean;
error?: string;
queue_processed?: boolean;
title?: string;
}) {
Expand Down Expand Up @@ -375,6 +377,9 @@
}
allMessages = [...allMessages];
}
if (data.error) {
toast.error(data.error, { duration: 8000 });
}
if (data.done) {
// Clear streaming indicator for this tab
if (tabId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
</script>

<div
class="fixed z-50 w-72 max-h-48 overflow-y-auto rounded-xl bg-white dark:bg-[#1a1a1a] border border-gray-150 dark:border-white/6 shadow-xl p-0.5"
class="fixed z-50 w-64 max-h-48 overflow-y-auto rounded-xl bg-white dark:bg-[#1a1a1a] border border-gray-150 dark:border-white/6 shadow-xl p-0.5"
>
{#if items.length === 0}
<div class="px-3 py-2 text-xs text-gray-400 dark:text-gray-600">No skills found</div>
Expand Down
Loading
Loading