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
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.9
3.13
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ 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.2.1] - 2026-06-11

### Added

- 🔍 **Global search modal.** New unified search (⌘K / ⌘⇧F) that finds chats by title *and* message content, plus files by name — across all workspaces or scoped to the active one. Shows recent chats when the query is empty. Replaces the old QuickOpen modal.
- 🌐 **Perplexity web search provider.** Added Perplexity as a first-class search provider (auto-detected between Exa and Tavily when `PERPLEXITY_API_KEY` is set).
- 🌐 **Chat Completions search provider.** Any OpenAI-compatible `/chat/completions` endpoint (e.g. Perplexity Sonar, LiteLLM proxy) can now be used for web search. Configure via Settings or environment variables.
- ➕ **New Chat button per workspace.** Each workspace row in the sidebar now shows a pencil icon on hover to quickly create a new chat.

### Fixed

- 🔍 **Search no longer pinned to last workspace.** When on a non-workspace page (e.g. Automations), search results are now global instead of silently scoped to the last-selected workspace.
- 🔄 **Sidebar chat cache invalidated globally.** Chat events from automations or other tabs now clear the cache for *all* workspaces, so re-expanding any workspace shows fresh data.
- 🔄 **New chats from automations appear in landing page.** The chat landing now debounce-reloads when it receives a socket event for an unknown chat, so automation-created chats appear without a manual refresh.
- 🏷️ **Chat tab labels update from DB title.** Opening a chat from search or sidebar now shows the real title instead of a generic "Chat" label.
- 🔁 **Duplicate new-chat tabs prevented.** Opening a new chat reuses an existing empty/pending chat tab instead of creating duplicates.

### Changed

- 🔗 **URL-driven navigation for chat and file intents.** Clicking a chat or file in the sidebar/search now uses URL query params (`chatId`, `file`, `dir`) which are consumed and cleaned up after navigation, enabling deep-linking and back-button support.
- 🍞 **Improved toast notifications.** Toaster now uses rich colors and close buttons for better visibility.
- 🔒 **Automations sidebar link hidden when chat is disabled.** The Automations nav item is now conditionally shown only when the chat/LLM backend is available.
- ⬆️ **Python 3.10 minimum.** Bumped `requires-python` from `>=3.9` to `>=3.10`.
- 📦 **Added `truststore` dependency.** Uses platform-native TLS certificate stores for better certificate handling.
- 🌍 **i18n for search UI.** Search-related strings added across all 10 supported locales.

## [0.2.0] - 2026-06-09

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Read our [Manifesto](MANIFESTO.md).
pip install cptr
```

Or with [uv](https://docs.astral.sh/uv/): `uvx cptr@latest run`

## Run

```bash
Expand Down
10 changes: 10 additions & 0 deletions cptr/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
files_router,
git_router,
proxy_router,
search_router,
state_router,
terminal_router,
workspace_router,
Expand All @@ -27,6 +28,14 @@

@app.on_event("startup")
async def startup():
# Use OS certificate store (Windows CertStore, macOS Keychain, etc.)
# instead of the bundled certifi CA bundle — fixes #31.
import logging as _logging
import truststore

truststore.inject_into_ssl()
_logging.getLogger(__name__).info("truststore: using system certificate store")

await init_db()
from cptr.env import STARTUP_TOKEN

Expand Down Expand Up @@ -182,6 +191,7 @@ async def get_config():
app.include_router(files_router)
app.include_router(git_router)
app.include_router(proxy_router)
app.include_router(search_router)
app.include_router(state_router)
app.include_router(terminal_router)
app.include_router(workspace_router)
Expand Down
47 changes: 47 additions & 0 deletions cptr/frontend/src/lib/apis/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Unified search API: chats + files in one request.
*/
import { fetchJSON } from '$lib/apis';

export interface ChatSearchResult {
id: string;
title: string;
summary: string | null;
workspace: string;
updated_at: number;
created_at: number;
match_type: 'title' | 'message';
snippet: string | null;
}

export interface FileSearchResult {
path: string;
name: string;
type: 'file' | 'directory';
workspace: string;
}

export interface UnifiedSearchResponse {
chats: ChatSearchResult[];
files: FileSearchResult[];
}

export const unifiedSearch = (
query: string,
workspaces: string[],
workspace?: string,
chatLimit = 10,
fileLimit = 10
) => {
const params = new URLSearchParams({
q: query,
chat_limit: String(chatLimit),
file_limit: String(fileLimit)
});
if (workspace) params.set('workspace', workspace);
workspaces.forEach((w) => params.append('workspaces', w));
return fetchJSON<UnifiedSearchResponse>(`/api/search?${params}`);
};

export const getRecentChats = (limit = 9) =>
fetchJSON<{ chats: ChatSearchResult[] }>(`/api/search/recent?limit=${limit}`);
72 changes: 72 additions & 0 deletions cptr/frontend/src/lib/components/Admin/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,21 @@
let exaKey = $state('');
let tavilyKey = $state('');
let braveKey = $state('');
let perplexityKey = $state('');
let ccKey = $state('');
let ccBaseUrl = $state('');
let ccModel = $state('');

async function loadConfig() {
try {
config = await getAdminConfig();
exaKey = config['web.exa_api_key'] || '';
tavilyKey = config['web.tavily_api_key'] || '';
braveKey = config['web.brave_api_key'] || '';
perplexityKey = config['web.perplexity_api_key'] || '';
ccKey = config['web.chat_completions_api_key'] || '';
ccBaseUrl = config['web.chat_completions_base_url'] || '';
ccModel = config['web.chat_completions_model'] || '';
} catch {
toast.error($t('admin.failedToLoadConfig'));
} finally {
Expand Down Expand Up @@ -106,7 +114,9 @@
<option value="exa">Exa</option>
<option value="tavily">Tavily</option>
<option value="brave">Brave</option>
<option value="perplexity">Perplexity</option>
<option value="duckduckgo">DuckDuckGo</option>
<option value="chat_completions">{$t('admin.webChatCompletions')}</option>
</select>
</div>

Expand Down Expand Up @@ -170,6 +180,68 @@
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-2">
{$t('admin.webDuckDuckGoNote')}
</p>
{:else if provider === 'perplexity'}
<div class="mt-3">
<label class="block text-[13px] text-gray-700 dark:text-gray-300 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"
placeholder="pplx-..."
bind:value={perplexityKey}
onblur={() => saveKey('web.perplexity_api_key', perplexityKey)}
disabled={saving}
/>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-0.5">
{$t('admin.webPerplexityHint')}
</p>
</div>
{: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"
>{$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"
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"
>{$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"
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"
>{$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"
placeholder="sonar-pro"
bind:value={ccModel}
onblur={() => saveKey('web.chat_completions_model', ccModel)}
disabled={saving}
/>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600">
{$t('admin.webCcHint')}
</p>
</div>
{/if}
{/if}

Expand Down
Loading
Loading