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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ 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.4.0] - 2026-06-13

### Added

- 🔌 **Tool servers.** Connect external tools via MCP or OpenAPI. Add servers from the new Tool Servers admin tab, verify the connection, and the AI can use them immediately. Supports bearer auth and custom headers.
- 🤖 **Sub-agents.** The AI can now spin up sub-agents to work on tasks in parallel. Each sub-agent gets full tool access and runs as a real chat you can inspect afterwards. Configure concurrency and limits from the new Subagents admin tab.
- ⚡ **Parallel tool execution.** When the AI makes multiple tool calls in one response, they now run concurrently instead of one at a time.
- 🔐 **Signup toggle.** Enable or disable user registration directly from the Users admin tab.
- 🧠 **Context compaction threshold in UI.** The token threshold for automatic context compaction can now be set from the Models admin tab instead of editing `config.toml`.

### Changed

- 🏗️ **Admin panel restructure.** The old Settings and Browser panels have been split into focused tabs: **Web** (search providers + browser backends), **Tool Servers**, and **Subagents**.
- 🌍 **Comprehensive i18n.** 200+ new translation keys across all 10 locales. Almost every remaining hardcoded English string in the UI is now translatable.
- 🏷️ **External tool labels.** Tool calls from external servers show the tool name and server in the chat UI. Sub-agent tasks show as `Sub-agent: "task…"`.

### Fixed

- 🐛 **Plan mode with external tools.** Plan mode now correctly sees tools from connected external servers, not just built-in ones.

## [0.3.5] - 2026-06-13

### Added
Expand Down
2 changes: 1 addition & 1 deletion cptr/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.5",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down
38 changes: 38 additions & 0 deletions cptr/frontend/src/lib/apis/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,41 @@ export const updateModelConfig = (
...jsonBody(update),
method: 'PUT'
});

// ── Tool Servers ────────────────────────────────────────────

export interface ToolServer {
id: string;
type: 'openapi' | 'mcp';
url: string;
path: string;
auth_type: string;
key: string;
name: string;
description: string;
headers: Record<string, string> | null;
enabled: boolean;
}

export const listToolServers = async (): Promise<ToolServer[]> => {
const data = await fetchJSON<{ servers: ToolServer[] }>('/api/admin/tools/servers');
return data.servers;
};

export const createToolServer = (server: Omit<ToolServer, 'id'>) =>
fetchJSON('/api/admin/tools/servers', jsonBody(server));

export const updateToolServer = (id: string, updates: Partial<Omit<ToolServer, 'id'>>) =>
fetchJSON(`/api/admin/tools/servers/${id}`, {
...jsonBody(updates),
method: 'PUT'
});

export const deleteToolServer = (id: string) =>
fetchJSON(`/api/admin/tools/servers/${id}`, { method: 'DELETE' });

export const verifyToolServer = (id: string) =>
fetchJSON<{ ok: boolean; tools?: { name: string; description: string }[]; message?: string }>(
`/api/admin/tools/servers/${id}/verify`,
{ method: 'POST' }
);
34 changes: 17 additions & 17 deletions cptr/frontend/src/lib/components/Admin/AudioSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -52,61 +52,61 @@
toast.success($t('settings.saved'));
refreshAudioState();
} catch {
toast.error('Failed to save audio settings');
toast.error($t('admin.audio.saveFailed'));
} finally {
saving = false;
}
}
</script>

<div class="flex flex-col min-h-full">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">Audio</h2>
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{$t('admin.audio.title')}</h2>

{#if loading}
<div class="flex justify-center py-8"><Spinner size={16} /></div>
{:else}
<!-- Voice Notes -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">Voice Memos</h3>
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">{$t('admin.audio.voiceMemos')}</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">Enable Voice Memos</span>
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.audio.enableVoiceMemos')}</span>
<ToggleSwitch value={voiceMemosEnabled} onchange={(v) => { voiceMemosEnabled = v; }} />
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
Record voice memos from the "+" menu.
{$t('admin.audio.voiceMemosHint')}
</p>

<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">Auto-transcribe</span>
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.audio.autoTranscribe')}</span>
<ToggleSwitch value={transcribeEnabled} onchange={(v) => { transcribeEnabled = v; }} />
</label>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{transcribeEnabled ? 'Recordings are transcribed to markdown via STT.' : 'Recordings are saved as audio only.'}
{transcribeEnabled ? $t('admin.audio.transcribeOnHint') : $t('admin.audio.transcribeOffHint')}
</p>

<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">Recording quality</span>
<span class="text-xs text-gray-600 dark:text-gray-400">{$t('admin.audio.recordingQuality')}</span>
<select
bind:value={quality}
class="bg-transparent text-xs text-gray-600 dark:text-gray-400 outline-none cursor-pointer"
>
<option value="high">High (128kbps)</option>
<option value="medium">Medium (64kbps)</option>
<option value="low">Low (32kbps)</option>
<option value="high">{$t('admin.audio.qualityHigh')}</option>
<option value="medium">{$t('admin.audio.qualityMedium')}</option>
<option value="low">{$t('admin.audio.qualityLow')}</option>
</select>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 -mt-1">
{quality === 'high' ? 'Best quality, larger files.' : quality === 'medium' ? 'Balanced quality and size.' : 'Smallest files, optimized for speech.'}
{quality === 'high' ? $t('admin.audio.qualityHintHigh') : quality === 'medium' ? $t('admin.audio.qualityHintMedium') : $t('admin.audio.qualityHintLow')}
</p>
</div>

<!-- Speech-to-Text -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Speech-to-Text</h3>
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">{$t('admin.audio.stt')}</h3>

<div class="flex flex-col gap-2.5">
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-base-url">Base URL</label>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-base-url">{$t('connections.baseUrl')}</label>
<input
id="stt-base-url"
type="text"
Expand All @@ -116,7 +116,7 @@
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-api-key">API Key</label>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-api-key">{$t('connections.apiKey')}</label>
<input
id="stt-api-key"
type="password"
Expand All @@ -126,7 +126,7 @@
/>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-model">Model</label>
<label class="text-xs text-gray-600 dark:text-gray-400" for="stt-model">{$t('automations.model')}</label>
<input
id="stt-model"
type="text"
Expand All @@ -136,7 +136,7 @@
/>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600">
Compatible with OpenAI's audio/transcriptions API.
{$t('admin.audio.sttHint')}
</p>
</div>

Expand Down
36 changes: 18 additions & 18 deletions cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,16 @@
);

let selectedWsName = $derived(
$workspaceList.find((w) => w.path === workspace)?.name || workspace.split('/').pop() || 'Select workspace'
$workspaceList.find((w) => w.path === workspace)?.name || workspace.split('/').pop() || $t('automationModal.selectWorkspace')
);

const platformHints: Record<string, string> = {
telegram: 'Create a bot via @BotFather',
discord: 'Create a bot in the Developer Portal',
slack: 'Bot Token | App Token (pipe-separated)',
whatsapp: 'Access Token | Phone Number ID (pipe-separated)',
signal: 'signal-cli URL | Phone Number (pipe-separated)'
};
const platformHints: Record<string, string> = $derived({
telegram: $t('messaging.hint.telegram'),
discord: $t('messaging.hint.discord'),
slack: $t('messaging.hint.slack'),
whatsapp: $t('messaging.hint.whatsapp'),
signal: $t('messaging.hint.signal')
});

async function handleVerify() {
if (!token.trim()) return;
Expand Down Expand Up @@ -106,7 +106,7 @@
onsave();
onclose();
} catch (e: any) {
toast.error(e.message || 'Failed to save');
toast.error(e.message || $t('messaging.failedToSave'));
} finally {
saving = false;
}
Expand All @@ -122,14 +122,14 @@
}}
>
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-3">
{bot ? 'Edit Bot' : 'Add Bot'}
{bot ? $t('messaging.editBot') : $t('messaging.addBot')}
</h2>

{#if !bot}
<!-- Platform + Name on same row -->
<div class="flex gap-3">
<div class="flex-1">
<label class="text-[10px] text-gray-400 dark:text-gray-600">Name</label>
<label class="text-[10px] text-gray-400 dark:text-gray-600">{$t('messaging.name')}</label>
<input
type="text"
bind:value={name}
Expand All @@ -141,7 +141,7 @@
/>
</div>
<div class="w-28 shrink-0">
<label class="text-[10px] text-gray-400 dark:text-gray-600">Platform</label>
<label class="text-[10px] text-gray-400 dark:text-gray-600">{$t('messaging.platform')}</label>
<select
bind:value={platform}
onchange={() => { verifyResult = null; }}
Expand All @@ -157,7 +157,7 @@
</div>
{:else}
<!-- Edit mode: just name -->
<label class="text-[10px] text-gray-400 dark:text-gray-600">Name</label>
<label class="text-[10px] text-gray-400 dark:text-gray-600">{$t('messaging.name')}</label>
<input
type="text"
bind:value={name}
Expand All @@ -177,7 +177,7 @@
<input
type="password"
bind:value={token}
placeholder={bot ? 'Leave empty to keep current' : platformHints[platform] || 'Paste token'}
placeholder={bot ? $t('messaging.tokenKeep') : platformHints[platform] || $t('messaging.tokenPaste')}
autocomplete="new-password"
class="flex-1 bg-transparent text-[13px] text-gray-700 dark:text-gray-300 placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none py-0.5 font-mono"
/>
Expand All @@ -191,7 +191,7 @@
{#if verifying}
<Spinner size={10} />
{:else}
Verify
{$t('messaging.verify')}
{/if}
</button>
{/if}
Expand All @@ -208,8 +208,8 @@
{/if}

<!-- Allowed senders -->
<label class="text-[10px] text-gray-400 dark:text-gray-600 mt-1">Allowed senders</label>
<p class="text-[10px] text-gray-300 dark:text-gray-700 mb-0.5">Comma-separated user IDs. Leave empty to allow all.</p>
<label class="text-[10px] text-gray-400 dark:text-gray-600 mt-1">{$t('messaging.allowedSenders')}</label>
<p class="text-[10px] text-gray-300 dark:text-gray-700 mb-0.5">{$t('messaging.allowedSendersHint')}</p>
<input
type="text"
bind:value={allowedSenders}
Expand Down Expand Up @@ -249,7 +249,7 @@
{#if saving}
<Spinner size={14} />
{:else}
{bot ? 'Save' : 'Add'} →
{bot ? $t('messaging.save') : $t('messaging.add')}
{/if}
</button>
</div>
Expand Down
12 changes: 6 additions & 6 deletions cptr/frontend/src/lib/components/Admin/Gateway.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@
class="shrink-0 text-[11px] text-gray-500 hover:text-gray-900 dark:text-gray-500 dark:hover:text-white transition-colors"
onclick={copyKey}
>
Copy
{$t('admin.gateway.copy')}
</button>
</div>
<div class="flex items-center gap-2">
Expand All @@ -183,7 +183,7 @@
</div>
{/if}

<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">Keys</h3>
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">{$t('admin.gateway.keys')}</h3>
<div class="flex items-center gap-2 mb-4">
<input
type="text"
Expand Down Expand Up @@ -246,23 +246,23 @@
</h3>
<div class="space-y-1.5 text-[11px] font-mono text-gray-600 dark:text-gray-400">
<div>
<span class="text-gray-400 dark:text-gray-600">Base URL:</span>
<span class="text-gray-400 dark:text-gray-600">{$t('admin.gateway.baseUrl')}</span>
<span class="text-gray-700 dark:text-gray-300"
>{`${typeof window !== 'undefined' ? window.location.origin : ''}/v1`}</span
>
</div>
<div>
<span class="text-gray-400 dark:text-gray-600">API Key:</span>
<span class="text-gray-400 dark:text-gray-600">{$t('admin.gateway.apiKey')}</span>
<span class="text-gray-700 dark:text-gray-300">sk-cptr-...</span>
</div>
<div class="pt-1">
<div class="flex items-center justify-between gap-2 mb-1">
<span class="text-gray-400 dark:text-gray-600">Headers:</span>
<span class="text-gray-400 dark:text-gray-600">{$t('admin.gateway.headers')}</span>
<button
class="shrink-0 text-[11px] font-sans text-gray-500 hover:text-gray-900 dark:text-gray-500 dark:hover:text-white transition-colors"
onclick={copyHeaders}
>
Copy
{$t('admin.gateway.copy')}
</button>
</div>
<button
Expand Down
8 changes: 4 additions & 4 deletions cptr/frontend/src/lib/components/Admin/Messaging.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
try {
bots = await listBots();
} catch {
toast.error('Failed to load bots');
toast.error($t('messaging.failedToLoad'));
} finally {
loading = false;
}
Expand Down Expand Up @@ -45,7 +45,7 @@
} catch {
bot.is_running = wasRunning;
bots = [...bots];
toast.error('Failed to toggle bot');
toast.error($t('messaging.failedToToggle'));
}
}

Expand All @@ -55,7 +55,7 @@
await deleteBot(bot.id);
await load();
} catch {
toast.error('Failed to delete bot');
toast.error($t('messaging.failedToDelete'));
}
}

Expand Down Expand Up @@ -131,7 +131,7 @@
{/each}

{#if bots.length === 0}
<p class="text-[13px] text-gray-400 dark:text-gray-600 py-4">No messaging bots configured.</p>
<p class="text-[13px] text-gray-400 dark:text-gray-600 py-4">{$t('messaging.noBots')}</p>
{/if}
</div>
{/if}
Expand Down
Loading
Loading