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

### Added

- 🎙️ **Voice memos.** Record audio memos directly from the "+" menu or ⌘⇧M. Recordings are saved to your workspace as audio files with an auto-generated markdown transcript. Uses a save-first architecture with IndexedDB backup so your recording is never lost, even if the network drops. Transcription is powered by any OpenAI-compatible STT API (Whisper, etc.), configurable in Settings > Audio.
- 🔊 **Audio admin settings.** New Audio tab in admin settings with separate controls for enabling voice memos, toggling auto-transcription, and configuring STT credentials (base URL, API key, model).

### Changed

- 🎨 **Consistent admin input styling.** Standardized all input fields in the Configuration admin panel to match the Browser settings design. Uniform height, background, focus states, and label sizing across all admin tabs.

## [0.3.1] - 2026-06-12

### Added
Expand Down
2 changes: 2 additions & 0 deletions cptr/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from cptr.routers import (
admin_router,
audio_router,
auth_router,
automations_router,
bridge_router,
Expand Down Expand Up @@ -209,6 +210,7 @@ async def get_config():

# Routers
app.include_router(admin_router)
app.include_router(audio_router)
app.include_router(auth_router)
app.include_router(automations_router)
app.include_router(bridge_router)
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.1",
"version": "0.3.2",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down
152 changes: 152 additions & 0 deletions cptr/frontend/src/lib/components/Admin/AudioSettings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<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';
import { refreshAudioState } from '$lib/stores/audio';

let loading = $state(true);
let saving = $state(false);

// Config state
let voiceMemosEnabled = $state(false);
let transcribeEnabled = $state(true);
let quality = $state<'high' | 'medium' | 'low'>('high');
let sttBaseUrl = $state('https://api.openai.com/v1');
let sttApiKey = $state('');
let sttModel = $state('whisper-1');
let hasExistingKey = $state(false);

onMount(async () => {
try {
const config = await getAdminConfig();
voiceMemosEnabled = config['audio.voice_memos_enabled'] === true;
transcribeEnabled = config['audio.transcribe_enabled'] !== false;
const q = config['audio.recording_quality'];
if (q === 'medium' || q === 'low') quality = q;
else quality = 'high';
sttBaseUrl = (config['audio.stt_base_url'] as string) || 'https://api.openai.com/v1';
sttModel = (config['audio.stt_model'] as string) || 'whisper-1';
hasExistingKey = !!config['audio.stt_api_key'];
} catch {}
loading = false;
});

async function save() {
saving = true;
try {
const cfg: Record<string, unknown> = {
'audio.voice_memos_enabled': voiceMemosEnabled,
'audio.transcribe_enabled': transcribeEnabled,
'audio.recording_quality': quality,
'audio.stt_base_url': sttBaseUrl,
'audio.stt_model': sttModel
};
if (sttApiKey) {
cfg['audio.stt_api_key'] = sttApiKey;
}
await updateConfig(cfg);
if (sttApiKey) hasExistingKey = true;
toast.success($t('settings.saved'));
refreshAudioState();
} catch {
toast.error('Failed to save audio settings');
} 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>

{#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>

<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>
<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.
</p>

<label class="flex items-center justify-between cursor-pointer">
<span class="text-xs text-gray-600 dark:text-gray-400">Auto-transcribe</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.'}
</p>

<div class="flex items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">Recording quality</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>
</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.'}
</p>
</div>

<!-- Speech-to-Text -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2 mt-5">Speech-to-Text</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>
<input
id="stt-base-url"
type="text"
bind:value={sttBaseUrl}
placeholder="https://api.openai.com/v1"
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="stt-api-key">API Key</label>
<input
id="stt-api-key"
type="password"
bind:value={sttApiKey}
placeholder={hasExistingKey ? '••••••••' : 'sk-...'}
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="stt-model">Model</label>
<input
id="stt-model"
type="text"
bind:value={sttModel}
placeholder="whisper-1"
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>
<p class="text-[11px] text-gray-400 dark:text-gray-600">
Compatible with OpenAI's audio/transcriptions API.
</p>
</div>

<!-- 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>
28 changes: 14 additions & 14 deletions cptr/frontend/src/lib/components/Admin/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,12 @@
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-2">{$t('admin.webAutoHint')}</p>
{:else if provider === 'exa'}
<div class="mt-3">
<label class="block text-[13px] text-gray-700 dark:text-gray-300 mb-1"
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webExaKey')}</label
>
<input
type="password"
class="w-full text-[13px] bg-gray-50 dark:bg-white/4 border border-gray-200 dark:border-white/8 rounded-lg px-2.5 py-1.5 outline-none text-gray-700 dark:text-gray-300 placeholder:text-gray-400 dark:placeholder:text-gray-600"
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"
placeholder="exa-..."
bind:value={exaKey}
onblur={() => saveKey('web.exa_api_key', exaKey)}
Expand All @@ -144,12 +144,12 @@
</div>
{:else if provider === 'tavily'}
<div class="mt-3">
<label class="block text-[13px] text-gray-700 dark:text-gray-300 mb-1"
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webTavilyKey')}</label
>
<input
type="password"
class="w-full text-[13px] bg-gray-50 dark:bg-white/4 border border-gray-200 dark:border-white/8 rounded-lg px-2.5 py-1.5 outline-none text-gray-700 dark:text-gray-300 placeholder:text-gray-400 dark:placeholder:text-gray-600"
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"
placeholder="tvly-..."
bind:value={tavilyKey}
onblur={() => saveKey('web.tavily_api_key', tavilyKey)}
Expand All @@ -161,12 +161,12 @@
</div>
{:else if provider === 'brave'}
<div class="mt-3">
<label class="block text-[13px] text-gray-700 dark:text-gray-300 mb-1"
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webBraveKey')}</label
>
<input
type="password"
class="w-full text-[13px] bg-gray-50 dark:bg-white/4 border border-gray-200 dark:border-white/8 rounded-lg px-2.5 py-1.5 outline-none text-gray-700 dark:text-gray-300 placeholder:text-gray-400 dark:placeholder:text-gray-600"
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"
placeholder="BSA..."
bind:value={braveKey}
onblur={() => saveKey('web.brave_api_key', braveKey)}
Expand All @@ -182,12 +182,12 @@
</p>
{:else if provider === 'perplexity'}
<div class="mt-3">
<label class="block text-[13px] text-gray-700 dark:text-gray-300 mb-1"
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webPerplexityKey')}</label
>
<input
type="password"
class="w-full text-[13px] bg-gray-50 dark:bg-white/4 border border-gray-200 dark:border-white/8 rounded-lg px-2.5 py-1.5 outline-none text-gray-700 dark:text-gray-300 placeholder:text-gray-400 dark:placeholder:text-gray-600"
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"
placeholder="pplx-..."
bind:value={perplexityKey}
onblur={() => saveKey('web.perplexity_api_key', perplexityKey)}
Expand All @@ -200,38 +200,38 @@
{:else if provider === 'chat_completions'}
<div class="mt-3 space-y-3">
<div>
<label class="block text-[13px] text-gray-700 dark:text-gray-300 mb-1"
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webCcBaseUrl')}</label
>
<input
type="text"
class="w-full text-[13px] bg-gray-50 dark:bg-white/4 border border-gray-200 dark:border-white/8 rounded-lg px-2.5 py-1.5 outline-none text-gray-700 dark:text-gray-300 placeholder:text-gray-400 dark:placeholder:text-gray-600"
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"
placeholder="https://api.perplexity.ai/v1"
bind:value={ccBaseUrl}
onblur={() => saveKey('web.chat_completions_base_url', ccBaseUrl)}
disabled={saving}
/>
</div>
<div>
<label class="block text-[13px] text-gray-700 dark:text-gray-300 mb-1"
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webCcKey')}</label
>
<input
type="password"
class="w-full text-[13px] bg-gray-50 dark:bg-white/4 border border-gray-200 dark:border-white/8 rounded-lg px-2.5 py-1.5 outline-none text-gray-700 dark:text-gray-300 placeholder:text-gray-400 dark:placeholder:text-gray-600"
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"
placeholder="sk-..."
bind:value={ccKey}
onblur={() => saveKey('web.chat_completions_api_key', ccKey)}
disabled={saving}
/>
</div>
<div>
<label class="block text-[13px] text-gray-700 dark:text-gray-300 mb-1"
<label class="text-xs text-gray-600 dark:text-gray-400 mb-1"
>{$t('admin.webCcModel')}</label
>
<input
type="text"
class="w-full text-[13px] bg-gray-50 dark:bg-white/4 border border-gray-200 dark:border-white/8 rounded-lg px-2.5 py-1.5 outline-none text-gray-700 dark:text-gray-300 placeholder:text-gray-400 dark:placeholder:text-gray-600"
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"
placeholder="sonar-pro"
bind:value={ccModel}
onblur={() => saveKey('web.chat_completions_model', ccModel)}
Expand Down
24 changes: 23 additions & 1 deletion cptr/frontend/src/lib/components/GroupTabBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@
} from '$lib/stores';
import { openChatTab } from '$lib/stores';
import { chatEnabled, streamingChatTabs } from '$lib/stores/chat';
import { voiceMemosEnabled, showVoiceMemo } from '$lib/stores/audio';
import { keybindings, formatChord } from '$lib/stores/keybindings';
import Icon from './Icon.svelte';
import Spinner from './common/Spinner.svelte';
import DropdownMenu from './DropdownMenu.svelte';
import { tooltip } from '$lib/tooltip';
import { t } from '$lib/i18n';
import VoiceMemoModal from './VoiceMemoModal.svelte';

interface Props {
group: EditorGroup;
Expand Down Expand Up @@ -159,7 +161,19 @@
onclick: () => {
openTerminalTab(group.id);
}
}
},
...($voiceMemosEnabled
? [
{
label: 'Voice Memo',
icon: 'microphone',
shortcut: formatChord($keybindings.voiceMemo),
onclick: () => {
showVoiceMemo.set(true);
}
}
]
: [])
]);

const contextMenuItems = $derived.by(() => {
Expand Down Expand Up @@ -391,6 +405,14 @@
/>
{/if}

{#if $showVoiceMemo}
<VoiceMemoModal
workspace={$activeWorkspace?.path ?? ''}
directory={$activeWorkspace?.fileBrowserCwd ?? $activeWorkspace?.path ?? ''}
onclose={() => showVoiceMemo.set(false)}
/>
{/if}

<style>
@reference "../../app.css";

Expand Down
7 changes: 7 additions & 0 deletions cptr/frontend/src/lib/components/Icon.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -338,5 +338,12 @@
<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" />
{:else if name === 'microphone'}
<path d="M12 1C10.3431 1 9 2.34315 9 4V12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12V4C15 2.34315 13.6569 1 12 1Z" />
<path d="M5 10V12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12V10" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
{:else if name === 'stop'}
<rect x="6" y="6" width="12" height="12" rx="1" fill="currentColor" />
{/if}
</svg>
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 @@ -10,6 +10,7 @@
import Connections from './Admin/Connections.svelte';
import Models from './Admin/Models.svelte';
import Messaging from './Admin/Messaging.svelte';
import AudioSettings from './Admin/AudioSettings.svelte';
import AdminSettings from './Admin/Settings.svelte';
import { session } from '$lib/session';
import { t } from '$lib/i18n';
Expand All @@ -24,6 +25,7 @@
| 'connections'
| 'models'
| 'messaging'
| 'audio'
| 'admin_settings';

interface Props {
Expand All @@ -49,6 +51,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: 'audio', label: 'Audio', icon: 'microphone' },
{ id: 'browser', label: 'Browser', icon: 'browser' },
{ id: 'admin_settings', label: $t('settings.configuration'), icon: 'shield' }
]);
Expand Down Expand Up @@ -123,6 +126,8 @@
<Models />
{:else if activeTab === 'messaging'}
<Messaging />
{:else if activeTab === 'audio'}
<AudioSettings />
{:else if activeTab === 'admin_settings'}
<AdminSettings />
{/if}
Expand Down
Loading
Loading