diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa59fa1..be2bca13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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.5.4] - 2026-06-18 + +### Added + +- ๐Ÿ“ฑ **Full PWA support.** cptr can now be installed as a standalone app on phones, tablets, and desktops. Includes offline caching, an offline fallback page, home screen shortcuts (New Chat, Open Workspace, New Note, New Terminal, Search), and a service worker that keeps static assets available when the server is unreachable. +- ๐Ÿ“ค **Share target.** Share files, text, or links from other apps directly into cptr. On mobile, use the system share sheet to send content straight to a chat. +- ๐Ÿ“‚ **File handling.** Opening supported file types (code, documents, images) with cptr now imports them into your workspace with a folder picker. +- ๐Ÿ“Š **Context usage indicator.** The chat panel now shows how full the context window is, so you can see at a glance how much room is left before compaction kicks in. +- ๐Ÿ—‚๏ธ **Workspace picker for imports.** When importing shared files or opening files from outside the app, a workspace picker lets you choose where to save them. +- ๐Ÿ”ง **PWA settings tab.** A new tab in Settings shows your install status, lets you check for service worker updates, and clear the offline cache. +- ๐Ÿ–ฅ๏ธ **Status modal.** A new status indicator in the chat panel shows server connection state and context usage at a glance. + +### Changed + +- ๐ŸŒ **Perplexity base URL is now configurable.** You can point the Perplexity search provider at a custom endpoint (like a LiteLLM proxy) from Settings or via the `PERPLEXITY_BASE_URL` environment variable. +- ๐Ÿค– **Simplified default system prompt.** Removed the instruction that told the AI to always create a plan before acting. The AI now helps directly unless you ask it to plan. +- ๐Ÿ“‚ **File uploads no longer overwrite existing files.** Uploading a file with the same name as an existing one now automatically adds a number suffix instead of replacing it. +- ๐ŸŒ **New translation keys.** Added PWA, share, file handling, and status labels across supported languages. + ## [0.5.3] - 2026-06-17 ### Added diff --git a/README.md b/README.md index dc50b34d..3678f291 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ `cptr` (short for "computer") runs on your machine and serves your whole computer (files, terminal, editor, git) to any browser. It literally is your computer. -Use it from your phone, tablet, laptop, another computer, or the machine it's running on. Designed to feel native on every screen. Plug in an AI that can actually read, write, and run things on your machine, or bring your favourite terminal agent. Terminal multiplexer, parallel AI agents, full workstation, one tool, any device. +Use it from your phone, tablet, laptop, another computer, or the machine it's running on. Designed to feel native on every screen. Plug in an AI that can actually read, write, and run things on your machine, or bring your favourite terminal agent. Terminal multiplexer, parallel AI agents, full workstation, one tool, your computer, any device. ## Install @@ -136,6 +136,9 @@ Close the tab. Come back tomorrow on any device. Everything is where you left it Life is short. Touch grass. Read our [Manifesto](MANIFESTO.md). +> Help us build open, empowering tools that put AI in people's hands. +> [We're hiring](http://careers.openwebui.com/). + ## Docker Run cptr with Docker: diff --git a/cptr/app.py b/cptr/app.py index d894cfad..4e416550 100644 --- a/cptr/app.py +++ b/cptr/app.py @@ -329,11 +329,14 @@ async def pwa_manifest(): name = f"cptr @ {hostname}" if hostname else "cptr" return { + "id": "/", "name": name, "short_name": "cptr", "description": f"Your computer, from anywhere. v{version}", "start_url": "/", + "scope": "/", "display": "standalone", + "display_override": ["window-controls-overlay", "standalone"], "orientation": "any", "background_color": "#000000", "theme_color": "#000000", @@ -348,6 +351,112 @@ async def pwa_manifest(): }, ], "categories": ["developer", "productivity", "utilities"], + "launch_handler": {"client_mode": ["navigate-existing", "auto"]}, + "handle_links": "preferred", + "note_taking": {"new_note_url": "/?intent=newNote"}, + "shortcuts": [ + { + "name": "New Chat", + "short_name": "Chat", + "url": "/?intent=newChat", + "icons": [{"src": "/icon-192.png", "sizes": "192x192"}], + }, + { + "name": "Open Workspace", + "short_name": "Workspace", + "url": "/?intent=openWorkspace", + "icons": [{"src": "/icon-192.png", "sizes": "192x192"}], + }, + { + "name": "New Note", + "short_name": "Note", + "url": "/?intent=newNote", + "icons": [{"src": "/icon-192.png", "sizes": "192x192"}], + }, + { + "name": "New Terminal", + "short_name": "Terminal", + "url": "/?intent=newTerminal", + "icons": [{"src": "/icon-192.png", "sizes": "192x192"}], + }, + { + "name": "Search", + "short_name": "Search", + "url": "/?intent=search", + "icons": [{"src": "/icon-192.png", "sizes": "192x192"}], + }, + ], + "share_target": { + "action": "/?intent=share", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": [ + "text/*", + "application/pdf", + "image/*", + ".md", + ".py", + ".js", + ".ts", + ".tsx", + ".jsx", + ".svelte", + ".json", + ".yaml", + ".yml", + ".toml", + ".rs", + ".go", + ], + } + ], + }, + }, + "file_handlers": [ + { + "action": "/?intent=importFiles", + "accept": { + "text/*": [ + ".txt", + ".md", + ".py", + ".js", + ".ts", + ".tsx", + ".jsx", + ".svelte", + ".css", + ".html", + ".json", + ".yaml", + ".yml", + ".toml", + ".rs", + ".go", + ".java", + ".c", + ".cpp", + ".h", + ".hpp", + ".sh", + ], + "application/pdf": [".pdf"], + "image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"], + }, + } + ], + "protocol_handlers": [ + {"protocol": "web+cptr", "url": "/?intent=%s"}, + ], + "prefer_related_applications": False, + "related_applications": [], } diff --git a/cptr/frontend/src/app.html b/cptr/frontend/src/app.html index c16e7799..ec5e73b4 100644 --- a/cptr/frontend/src/app.html +++ b/cptr/frontend/src/app.html @@ -12,10 +12,12 @@ + + %sveltekit.head% diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index ecd0afbb..e9deb291 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -28,9 +28,18 @@ export interface ChatInfo { is_active?: boolean; } +export interface ContextUsage { + tokens: number; + estimated_tokens: number; + threshold: number; + percent: number; + source: 'estimated'; +} + export interface ChatDetail { chat: ChatInfo; messages: ChatMessageRow[]; + context_usage?: ContextUsage | null; } export interface SendMessageResult { @@ -48,6 +57,16 @@ export interface ChatSendParams { voice_mode?: boolean; } +export interface CompactChatResult { + ok: boolean; + compacted: boolean; + reason?: string; + dropped_messages?: number; + kept_messages?: number; + summary_chars?: number; + context_usage?: ContextUsage | null; +} + // โ”€โ”€ Queries โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export const getChats = ( @@ -61,7 +80,10 @@ export const getChats = ( `/api/chats?workspace=${encodeURIComponent(workspace)}&limit=${limit}&offset=${offset}&sort_by=${sortBy}&sort_dir=${sortDir}` ); -export const getChat = (chatId: string) => fetchJSON(`/api/chats/${chatId}`); +export const getChat = (chatId: string, modelId?: string) => { + const suffix = modelId ? `?model_id=${encodeURIComponent(modelId)}` : ''; + return fetchJSON(`/api/chats/${chatId}${suffix}`); +}; export const deleteChat = (chatId: string) => fetchJSON<{ ok: boolean }>(`/api/chats/${chatId}`, { method: 'DELETE' }); @@ -106,6 +128,9 @@ export const approveToolCall = ( export const cancelTask = (chatId: string, messageId: string) => fetchJSON(`/api/chats/${chatId}/messages/${messageId}/cancel`, { method: 'POST' }); +export const compactChat = (chatId: string, modelId: string) => + fetchJSON(`/api/chats/${chatId}/compact`, jsonBody({ model_id: modelId })); + export const updateCurrentMessage = (chatId: string, messageId: string) => fetchJSON<{ ok: boolean }>(`/api/chats/${chatId}/current`, jsonBody({ message_id: messageId })); diff --git a/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte b/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte index 7c05c4d1..99e6aca9 100644 --- a/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte +++ b/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte @@ -97,280 +97,286 @@ } -
-

{$t('admin.audio.title')}

- +
{#if loading}
{:else} - -

{$t('admin.audio.voiceMemos')}

+
+

+ {$t('admin.audio.title')} +

+ + +

{$t('admin.audio.voiceMemos')}

-
- -

- {$t('admin.audio.voiceMemosHint')} -

+
+ +

+ {$t('admin.audio.voiceMemosHint')} +

- -

- {transcribeEnabled - ? $t('admin.audio.transcribeOnHint') - : $t('admin.audio.transcribeOffHint')} -

+ +

+ {transcribeEnabled + ? $t('admin.audio.transcribeOnHint') + : $t('admin.audio.transcribeOffHint')} +

-
- {$t('admin.audio.recordingQuality')} - +
+ {$t('admin.audio.recordingQuality')} + +
+

+ {quality === 'high' + ? $t('admin.audio.qualityHintHigh') + : quality === 'medium' + ? $t('admin.audio.qualityHintMedium') + : $t('admin.audio.qualityHintLow')} +

-

- {quality === 'high' - ? $t('admin.audio.qualityHintHigh') - : quality === 'medium' - ? $t('admin.audio.qualityHintMedium') - : $t('admin.audio.qualityHintLow')} -

-
- -

{$t('admin.audio.stt')}

+ +

{$t('admin.audio.stt')}

-
-
- - -
-
- - -
-
- - +
+
+ + +
+
+ + +
+
+ + +
+

+ {$t('admin.audio.sttHint')} +

-

- {$t('admin.audio.sttHint')} -

-
- -

{$t('admin.audio.tts')}

+ +

{$t('admin.audio.tts')}

-
- -

- {$t('admin.audio.ttsEnabledHint')} -

- -

- {$t('admin.audio.ttsAutoStreamHint')} -

-
- - -
-
- - -
-
- - -
-
- - -
-
- {$t('admin.audio.ttsFormat')} - -
-
-
+ +{#if showStatusModal} + (showStatusModal = false)} /> +{/if} diff --git a/cptr/frontend/src/lib/components/chat/FileSuggestionPopup.svelte b/cptr/frontend/src/lib/components/chat/FileSuggestionPopup.svelte index 0484a1e5..3b2725de 100644 --- a/cptr/frontend/src/lib/components/chat/FileSuggestionPopup.svelte +++ b/cptr/frontend/src/lib/components/chat/FileSuggestionPopup.svelte @@ -34,19 +34,21 @@
{#if items.length === 0} -
No files found
+
+ No files found +
{:else} -
+
Files
{#each items as item, i (item.id)} diff --git a/cptr/frontend/src/lib/components/chat/StatusModal.svelte b/cptr/frontend/src/lib/components/chat/StatusModal.svelte new file mode 100644 index 00000000..94d4a5d0 --- /dev/null +++ b/cptr/frontend/src/lib/components/chat/StatusModal.svelte @@ -0,0 +1,78 @@ + + + +
+
+

+ {$t('chat.commandStatus')} +

+ + {contextUsage ? `${contextPercent}%` : $t('chat.statusUnknown')} + +
+ +
+
+
+
+
+ + {$t('chat.statusEstimated')} + + + {#if contextUsage} + {tokenCount.toLocaleString()} / {contextUsage.threshold.toLocaleString()} + {:else} + {$t('chat.statusUnknown')} + {/if} + +
+
+ +
+
+ {$t('chat.statusChatId')} +
+ {#if chatId} + + {:else} +
+ {$t('chat.statusNoChat')} +
+ {/if} +
+
+
diff --git a/cptr/frontend/src/lib/i18n/locales/de.json b/cptr/frontend/src/lib/i18n/locales/de.json index b8b05e4f..8cb9d66b 100644 --- a/cptr/frontend/src/lib/i18n/locales/de.json +++ b/cptr/frontend/src/lib/i18n/locales/de.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo benรถtigt keinen API-Schlรผssel (Fallback).", "admin.webPerplexityKey": "Perplexity API-Schlรผssel", "admin.webPerplexityHint": "API-Schlรผssel unter perplexity.ai erhalten", + "admin.webPerplexityBaseUrl": "Basis-URL", + "admin.webPerplexityBaseUrlHint": "Leer lassen fรผr api.perplexity.ai. ร„ndern fรผr LiteLLM oder andere kompatible Proxys.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "Basis-URL", "admin.webCcKey": "API-Schlรผssel", diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 9e60fe75..ec67bbcb 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -91,6 +91,30 @@ "settings.back": "Back", "settings.general": "General", + "pwa.settingsTitle": "Progressive Web App", + "intent.chooseWorkspace": "Workspace", + "intent.newNote": "Note", + "intent.newChat": "Chat", + "intent.newTerminal": "Terminal", + "intent.share": "Share", + "intent.importFiles": "Import files", + "pwa.cancel": "Cancel", + "pwa.noWorkspaces": "No workspaces available.", + "pwa.shareBehavior": "Shared content", + "pwa.shareAsk": "Ask every time", + "pwa.shareChatDraft": "Chat draft", + "pwa.shareNoteFile": "Note file", + "pwa.fileImports": "File imports", + "pwa.workspaceRoot": "Workspace root", + "pwa.askFolder": "Ask for folder", + "pwa.configuredFolder": "Configured folder", + "pwa.importFolderPlaceholder": "Folder path or workspace-relative folder", + "pwa.resetTitle": "Troubleshooting", + "pwa.reset": "Reset PWA", + "pwa.resetting": "Resetting...", + "pwa.resetDesc": "Unregisters the service worker and clears the cached app shell. Your data stays.", + "pwa.unreachable": "Computer is unreachable", + "pwa.connectionRestored": "Connection restored", "settings.account": "Account", "settings.about": "About", "settings.save": "Save", @@ -283,6 +307,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo requires no API key (fallback).", "admin.webPerplexityKey": "Perplexity API Key", "admin.webPerplexityHint": "Get your API key at perplexity.ai", + "admin.webPerplexityBaseUrl": "Base URL", + "admin.webPerplexityBaseUrlHint": "Leave blank for api.perplexity.ai. Change for LiteLLM or other compatible proxies.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "Base URL", "admin.webCcKey": "API Key", @@ -540,6 +566,23 @@ "chat.scrollToBottom": "Scroll to bottom", "chat.removeUpload": "Remove upload", "chat.sendNow": "Send now", + "chat.commandCompact": "Compact", + "chat.commandCompactPercent": "{{percent}}% full", + "chat.commandPlan": "Plan mode", + "chat.commandPlanOn": "On", + "chat.commandPlanOff": "Off", + "chat.commandStatus": "Status", + "chat.commandStatusDesc": "Context usage, chat id", + "chat.statusContextUsage": "Context usage", + "chat.statusEstimated": "Estimated context", + "chat.statusChatId": "Chat id", + "chat.statusNoChat": "none", + "chat.statusUnknown": "unknown", + "chat.compacting": "Compacting context...", + "chat.compactDone": "Context compacted", + "chat.compactSkipped": "Nothing to compact yet", + "chat.compactFailed": "Failed to compact context", + "chat.compactNoChat": "Start a chat before compacting", "chat.prevMessage": "Previous message", "chat.nextMessage": "Next message", "chat.editMessage": "Edit message", @@ -799,5 +842,15 @@ "onboarding.suggestion3": "What files are here?", "onboarding.suggestion4": "Help me write tests", "onboarding.connectAiNudge": "Connect an AI provider to start chatting.", - "onboarding.goToConnections": "Settings โ†’ Connections" + "onboarding.goToConnections": "Settings โ†’ Connections", + + "chat.share": "Share", + + "general.storage": "Storage", + "general.cacheUsage": "Cache usage", + "general.persistentStorage": "Persistent storage", + "general.persistentYes": "Active", + "general.persistentNo": "Not granted", + "general.clearCache": "Clear cache", + "general.cacheCleared": "Cache cleared" } diff --git a/cptr/frontend/src/lib/i18n/locales/es.json b/cptr/frontend/src/lib/i18n/locales/es.json index f318917c..5b734f14 100644 --- a/cptr/frontend/src/lib/i18n/locales/es.json +++ b/cptr/frontend/src/lib/i18n/locales/es.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo no requiere clave de API (respaldo).", "admin.webPerplexityKey": "Clave de API de Perplexity", "admin.webPerplexityHint": "Obtรฉn tu clave de API en perplexity.ai", + "admin.webPerplexityBaseUrl": "URL base", + "admin.webPerplexityBaseUrlHint": "Dejar en blanco para api.perplexity.ai. Cambiar para LiteLLM u otros proxies compatibles.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "URL base", "admin.webCcKey": "Clave de API", diff --git a/cptr/frontend/src/lib/i18n/locales/fr.json b/cptr/frontend/src/lib/i18n/locales/fr.json index 86c7edb6..6498cd8c 100644 --- a/cptr/frontend/src/lib/i18n/locales/fr.json +++ b/cptr/frontend/src/lib/i18n/locales/fr.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo ne nรฉcessite aucune clรฉ API (solution de repli).", "admin.webPerplexityKey": "Clรฉ API Perplexity", "admin.webPerplexityHint": "Obtenez votre clรฉ API sur perplexity.ai", + "admin.webPerplexityBaseUrl": "URL de base", + "admin.webPerplexityBaseUrlHint": "Laisser vide pour api.perplexity.ai. Modifier pour LiteLLM ou d'autres proxies compatibles.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "URL de base", "admin.webCcKey": "Clรฉ API", diff --git a/cptr/frontend/src/lib/i18n/locales/ja.json b/cptr/frontend/src/lib/i18n/locales/ja.json index 4e5fc6c2..621170e4 100644 --- a/cptr/frontend/src/lib/i18n/locales/ja.json +++ b/cptr/frontend/src/lib/i18n/locales/ja.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGoใฏAPIใ‚ญใƒผไธ่ฆใงใ™๏ผˆใƒ•ใ‚ฉใƒผใƒซใƒใƒƒใ‚ฏ๏ผ‰ใ€‚", "admin.webPerplexityKey": "Perplexity APIใ‚ญใƒผ", "admin.webPerplexityHint": "perplexity.ai ใงAPIใ‚ญใƒผใ‚’ๅ–ๅพ—", + "admin.webPerplexityBaseUrl": "ใƒ™ใƒผใ‚นURL", + "admin.webPerplexityBaseUrlHint": "api.perplexity.ai ใ‚’ไฝฟ็”จใ™ใ‚‹ๅ ดๅˆใฏ็ฉบๆฌ„ใ€‚LiteLLM ใ‚„ไป–ใฎไบ’ๆ›ใƒ—ใƒญใ‚ญใ‚ทใฎๅ ดๅˆใฏๅค‰ๆ›ดใ—ใฆใใ ใ•ใ„ใ€‚", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "ใƒ™ใƒผใ‚นURL", "admin.webCcKey": "APIใ‚ญใƒผ", diff --git a/cptr/frontend/src/lib/i18n/locales/ko.json b/cptr/frontend/src/lib/i18n/locales/ko.json index f159f424..862a7623 100644 --- a/cptr/frontend/src/lib/i18n/locales/ko.json +++ b/cptr/frontend/src/lib/i18n/locales/ko.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo๋Š” API ํ‚ค๊ฐ€ ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค(๋Œ€์ฒด).", "admin.webPerplexityKey": "Perplexity API ํ‚ค", "admin.webPerplexityHint": "perplexity.ai์—์„œ API ํ‚ค๋ฅผ ๋ฐ›์œผ์„ธ์š”", + "admin.webPerplexityBaseUrl": "๊ธฐ๋ณธ URL", + "admin.webPerplexityBaseUrlHint": "api.perplexity.ai๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ๋น„์›Œ๋‘์„ธ์š”. LiteLLM ๋˜๋Š” ๊ธฐํƒ€ ํ˜ธํ™˜ ํ”„๋ก์‹œ์˜ ๊ฒฝ์šฐ ๋ณ€๊ฒฝํ•˜์„ธ์š”.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "๊ธฐ๋ณธ URL", "admin.webCcKey": "API ํ‚ค", diff --git a/cptr/frontend/src/lib/i18n/locales/pt-BR.json b/cptr/frontend/src/lib/i18n/locales/pt-BR.json index b797ae4e..81bb9f41 100644 --- a/cptr/frontend/src/lib/i18n/locales/pt-BR.json +++ b/cptr/frontend/src/lib/i18n/locales/pt-BR.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo nรฃo exige chave de API (fallback).", "admin.webPerplexityKey": "Chave de API do Perplexity", "admin.webPerplexityHint": "Obtenha sua chave de API em perplexity.ai", + "admin.webPerplexityBaseUrl": "URL base", + "admin.webPerplexityBaseUrlHint": "Deixe em branco para api.perplexity.ai. Altere para LiteLLM ou outros proxies compatรญveis.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "URL base", "admin.webCcKey": "Chave de API", diff --git a/cptr/frontend/src/lib/i18n/locales/ru.json b/cptr/frontend/src/lib/i18n/locales/ru.json index e887831a..9ee4ab3d 100644 --- a/cptr/frontend/src/lib/i18n/locales/ru.json +++ b/cptr/frontend/src/lib/i18n/locales/ru.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo ะฝะต ั‚ั€ะตะฑัƒะตั‚ API-ะบะปัŽั‡ะฐ (ั€ะตะทะตั€ะฒะฝั‹ะน ะฒะฐั€ะธะฐะฝั‚).", "admin.webPerplexityKey": "API-ะบะปัŽั‡ Perplexity", "admin.webPerplexityHint": "ะŸะพะปัƒั‡ะธั‚ะต API-ะบะปัŽั‡ ะฝะฐ perplexity.ai", + "admin.webPerplexityBaseUrl": "ะ‘ะฐะทะพะฒั‹ะน URL", + "admin.webPerplexityBaseUrlHint": "ะžัั‚ะฐะฒัŒั‚ะต ะฟัƒัั‚ั‹ะผ ะดะปั api.perplexity.ai. ะ˜ะทะผะตะฝะธั‚ะต ะดะปั LiteLLM ะธะปะธ ะดั€ัƒะณะธั… ัะพะฒะผะตัั‚ะธะผั‹ั… ะฟั€ะพะบัะธ.", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "ะ‘ะฐะทะพะฒั‹ะน URL", "admin.webCcKey": "API-ะบะปัŽั‡", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-CN.json b/cptr/frontend/src/lib/i18n/locales/zh-CN.json index 75a8636b..882e05bd 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-CN.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-CN.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo ไธ้œ€่ฆ API ๅฏ†้’ฅ๏ผˆๅค‡็”จ๏ผ‰ใ€‚", "admin.webPerplexityKey": "Perplexity API ๅฏ†้’ฅ", "admin.webPerplexityHint": "ๅœจ perplexity.ai ่Žทๅ–ไฝ ็š„ API ๅฏ†้’ฅ", + "admin.webPerplexityBaseUrl": "ๅŸบ็ก€ URL", + "admin.webPerplexityBaseUrlHint": "็•™็ฉบไฝฟ็”จ api.perplexity.aiใ€‚ๅฆ‚ไฝฟ็”จ LiteLLM ๆˆ–ๅ…ถไป–ๅ…ผๅฎนไปฃ็†่ฏทไฟฎๆ”นใ€‚", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "ๅŸบ็ก€ URL", "admin.webCcKey": "API ๅฏ†้’ฅ", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-TW.json b/cptr/frontend/src/lib/i18n/locales/zh-TW.json index cdceede6..f5c31085 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-TW.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-TW.json @@ -251,6 +251,8 @@ "admin.webDuckDuckGoNote": "DuckDuckGo ไธ้œ€่ฆ API ้‡‘้‘ฐ๏ผˆๅ‚™็”จ๏ผ‰ใ€‚", "admin.webPerplexityKey": "Perplexity API ้‡‘้‘ฐ", "admin.webPerplexityHint": "ๅœจ perplexity.ai ๅ–ๅพ—ไฝ ็š„ API ้‡‘้‘ฐ", + "admin.webPerplexityBaseUrl": "ๅŸบ็คŽ URL", + "admin.webPerplexityBaseUrlHint": "็•™็ฉบไฝฟ็”จ api.perplexity.aiใ€‚ๅฆ‚ไฝฟ็”จ LiteLLM ๆˆ–ๅ…ถไป–็›ธๅฎนไปฃ็†่ซ‹ไฟฎๆ”นใ€‚", "admin.webChatCompletions": "Chat Completions", "admin.webCcBaseUrl": "ๅŸบ็คŽ URL", "admin.webCcKey": "API ้‡‘้‘ฐ", diff --git a/cptr/frontend/src/lib/intents/payloadStore.ts b/cptr/frontend/src/lib/intents/payloadStore.ts new file mode 100644 index 00000000..ec517e4b --- /dev/null +++ b/cptr/frontend/src/lib/intents/payloadStore.ts @@ -0,0 +1,41 @@ +import type { SharePayload } from './types'; + +const DB_NAME = 'cptr-shares'; +const DB_VERSION = 1; +const SHARE_STORE = 'share-payloads'; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(SHARE_STORE)) db.createObjectStore(SHARE_STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export async function getSharePayload(id: string): Promise { + if (typeof indexedDB === 'undefined') return null; + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(SHARE_STORE, 'readonly'); + const req = tx.objectStore(SHARE_STORE).get(id); + req.onsuccess = () => resolve((req.result as SharePayload | undefined) ?? null); + req.onerror = () => reject(req.error); + tx.oncomplete = () => db.close(); + }); +} + +export async function deleteSharePayload(id: string): Promise { + if (typeof indexedDB === 'undefined') return; + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(SHARE_STORE, 'readwrite'); + const req = tx.objectStore(SHARE_STORE).delete(id); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + tx.oncomplete = () => db.close(); + }); +} diff --git a/cptr/frontend/src/lib/intents/types.ts b/cptr/frontend/src/lib/intents/types.ts new file mode 100644 index 00000000..e04d914d --- /dev/null +++ b/cptr/frontend/src/lib/intents/types.ts @@ -0,0 +1,57 @@ +export type LaunchIntentKind = + | 'newNote' + | 'newChat' + | 'newTerminal' + | 'openWorkspace' + | 'search' + | 'openChat' + | 'openFile' + | 'openDir' + | 'share' + | 'importFiles'; + +export type ShareBehavior = 'ask' | 'chatDraft' | 'noteFile'; +export type ImportDestination = 'workspaceRoot' | 'askFolder' | 'configuredFolder'; + +export interface PwaPreferences { + shareBehavior: ShareBehavior; + importDestination: ImportDestination; + importFolder?: string; +} + +export interface ShareFilePayload { + name: string; + type: string; + lastModified?: number; + file: File; +} + +export interface SharePayload { + id?: string; + title?: string; + text?: string; + url?: string; + files?: ShareFilePayload[]; +} + +export interface FileImportPayload { + files: File[]; +} + +export interface LaunchIntent { + kind: LaunchIntentKind; + workspace?: string; + chatId?: string | null; + filePath?: string; + dirPath?: string; + targetDir?: string; + payloadId?: string; + shareBehavior?: ShareBehavior; + share?: SharePayload; + importFiles?: FileImportPayload; +} + +export const defaultPwaPreferences: PwaPreferences = { + shareBehavior: 'ask', + importDestination: 'workspaceRoot' +}; diff --git a/cptr/frontend/src/lib/stores.ts b/cptr/frontend/src/lib/stores.ts index 787be490..cd43ede9 100644 --- a/cptr/frontend/src/lib/stores.ts +++ b/cptr/frontend/src/lib/stores.ts @@ -28,6 +28,7 @@ import { listSessions, createSession, deleteSession } from '$lib/apis/terminal'; import { changeLocale, i18next } from '$lib/i18n'; import { streamingChatTabs } from '$lib/stores/chat'; import { keybindings, loadKeybindings } from '$lib/stores/keybindings'; +import { defaultPwaPreferences, type PwaPreferences } from '$lib/intents/types'; // โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -36,6 +37,7 @@ export interface Tab { type: 'files' | 'terminal' | 'file' | 'git' | 'chat' | 'preview'; label: string; filePath?: string; + edit?: boolean; path?: string; // generic path (e.g. for chat) sessionId?: string; port?: number; @@ -78,6 +80,7 @@ export interface UserPreferences { selectedModelId?: string; // last selected chat model, synced across browsers requestParams?: Record; // arbitrary params merged into API request body showUpdateToast?: boolean; // show version update notifications (default true) + pwa?: PwaPreferences; } export type Theme = 'dark' | 'light' | 'system'; @@ -170,6 +173,7 @@ export const isGitRepo = writable(false); export type StreamingBehavior = 'queue' | 'interrupt'; export const streamingBehavior = writable('queue'); export const selectedModelId = writable(''); +export const pwaPreferences = writable(defaultPwaPreferences); /** Saved workspace path order for sidebar drag-reorder. */ export const workspaceOrder = writable([]); @@ -305,7 +309,8 @@ function persistPreferences(): void { version: get(lastSeenVersion), selectedModelId: get(selectedModelId) || undefined, requestParams: Object.keys(get(requestParams)).length ? get(requestParams) : undefined, - showUpdateToast: get(showUpdateToastPref) + showUpdateToast: get(showUpdateToastPref), + pwa: get(pwaPreferences) }; savePreferences(prefs as unknown as Record).catch(() => {}); }, 300); @@ -351,6 +356,9 @@ function subscribeForPersistence() { showUpdateToastPref.subscribe(() => { if (get(stateLoaded)) persistPreferences(); }); + pwaPreferences.subscribe(() => { + if (get(stateLoaded)) persistPreferences(); + }); i18next.on('languageChanged', () => { if (get(stateLoaded)) persistPreferences(); }); @@ -379,6 +387,12 @@ export async function loadPreferences(): Promise { if (prefs.selectedModelId) selectedModelId.set(prefs.selectedModelId as string); if (prefs.requestParams) requestParams.set(prefs.requestParams as Record); if (prefs.showUpdateToast !== undefined) showUpdateToastPref.set(prefs.showUpdateToast as boolean); + const pwaPrefs = prefs.pwa; + if (pwaPrefs) + pwaPreferences.set({ + ...defaultPwaPreferences, + ...(pwaPrefs as PwaPreferences) + }); } catch { // First run, no preferences yet } @@ -493,6 +507,46 @@ if (typeof window !== 'undefined') { }); } +// โ”€โ”€ Cross-tab state sync (BroadcastChannel) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Syncs theme and locale changes between browser tabs / PWA windows +// so the user doesn't see divergent state. + +if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel('cptr-sync'); + + let _syncingFromBroadcast = false; + + channel.onmessage = (e: MessageEvent) => { + const { type, value } = e.data || {}; + _syncingFromBroadcast = true; + try { + if (type === 'theme' && value) { + theme.set(value); + } else if (type === 'locale' && value) { + changeLocale(value); + } + } finally { + _syncingFromBroadcast = false; + } + }; + + theme.subscribe((t) => { + if (!_syncingFromBroadcast) { + channel.postMessage({ type: 'theme', value: t }); + } + }); + + // Locale changes are broadcast from changeLocale() calls; + // subscribe to i18next language changes. + if (i18next) { + i18next.on('languageChanged', (lng: string) => { + if (!_syncingFromBroadcast) { + channel.postMessage({ type: 'locale', value: lng }); + } + }); + } +} + // โ”€โ”€ Workspace actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** @@ -587,7 +641,11 @@ export function reorderTabs(oldIndex: number, newIndex: number, groupId?: string }); } -export function openFileTab(filePath: string, targetGroupId?: string): void { +export function openFileTab( + filePath: string, + targetGroupId?: string, + options: { edit?: boolean } = {} +): void { const ws = get(currentWorkspace); if (!ws) return; @@ -598,6 +656,13 @@ export function openFileTab(filePath: string, targetGroupId?: string): void { // Reuse existing tab within this group const existing = group.tabs.find((t) => t.type === 'file' && t.filePath === filePath); if (existing) { + if (options.edit) { + updateGroupTabs(gid, (tabs) => ({ + tabs: tabs.map((t) => (t.id === existing.id ? { ...t, edit: true } : t)), + activeTabId: existing.id + })); + return; + } setActiveTab(existing.id, gid); return; } @@ -607,7 +672,8 @@ export function openFileTab(filePath: string, targetGroupId?: string): void { id: nextId(), type: 'file', label: name, - filePath + filePath, + edit: options.edit }; updateGroupTabs(gid, (tabs) => ({ @@ -863,6 +929,24 @@ export function updateTabFilePath(tabId: string, newPath: string): void { }); } +export function clearTabEdit(tabId: string): void { + currentWorkspace.update((ws) => { + if (!ws) return ws; + return { + ...ws, + groups: ws.groups.map((g) => ({ + ...g, + tabs: g.tabs.map((t) => { + if (t.id !== tabId || !t.edit) return t; + const next = { ...t }; + delete next.edit; + return next; + }) + })) + }; + }); +} + // โ”€โ”€ Split / Editor Group actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** Open a file in a new split group (creates the group if needed) */ diff --git a/cptr/frontend/src/lib/stores/audio.ts b/cptr/frontend/src/lib/stores/audio.ts index 6840acd2..bbf7267e 100644 --- a/cptr/frontend/src/lib/stores/audio.ts +++ b/cptr/frontend/src/lib/stores/audio.ts @@ -60,7 +60,7 @@ export function getTtsAudioElement(): HTMLAudioElement | null { return ttsAudioElement; } -export function setTtsAudioPlaybackSource(src: string): HTMLAudioElement | null { +export function setTtsAudioPlaybackSource(src: string, title?: string): HTMLAudioElement | null { const audio = getTtsAudioElement(); if (!audio) return null; ttsAudioUseToken += 1; @@ -69,9 +69,51 @@ export function setTtsAudioPlaybackSource(src: string): HTMLAudioElement | null audio.muted = false; audio.playbackRate = currentTtsPlaybackSpeed; audio.src = src; + + // Shows playback controls on lock screen (Android) and + // Control Center (iOS) while TTS is playing. + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: title || 'cptr', + artist: 'cptr', + artwork: [ + { src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: '/icon-512.png', sizes: '512x512', type: 'image/png' } + ] + }); + navigator.mediaSession.setActionHandler('play', () => { + audio.play().catch(() => {}); + }); + navigator.mediaSession.setActionHandler('pause', () => { + audio.pause(); + }); + navigator.mediaSession.setActionHandler('stop', () => { + audio.pause(); + audio.currentTime = 0; + clearMediaSession(); + }); + + // Clear media session when playback ends naturally + const currentToken = ttsAudioUseToken; + audio.addEventListener('ended', function onEnd() { + audio.removeEventListener('ended', onEnd); + if (ttsAudioUseToken === currentToken) clearMediaSession(); + }); + } + return audio; } +/** Clear media session metadata and handlers. */ +export function clearMediaSession() { + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = null; + navigator.mediaSession.setActionHandler('play', null); + navigator.mediaSession.setActionHandler('pause', null); + navigator.mediaSession.setActionHandler('stop', null); + } +} + export async function unlockTtsAudioPlayback() { if (typeof window === 'undefined') return; diff --git a/cptr/frontend/src/lib/stores/socket.svelte.ts b/cptr/frontend/src/lib/stores/socket.svelte.ts index 5e1e7fe4..9d8b5302 100644 --- a/cptr/frontend/src/lib/stores/socket.svelte.ts +++ b/cptr/frontend/src/lib/stores/socket.svelte.ts @@ -28,8 +28,9 @@ function connect() { } function disconnect() { - socket?.disconnect(); + const currentSocket = socket; socket = null; + currentSocket?.disconnect(); _connected = false; } diff --git a/cptr/frontend/src/routes/+layout.svelte b/cptr/frontend/src/routes/+layout.svelte index 72dd8d74..47a87ab7 100644 --- a/cptr/frontend/src/routes/+layout.svelte +++ b/cptr/frontend/src/routes/+layout.svelte @@ -14,7 +14,7 @@ import AuthScreen from '$lib/components/AuthScreen.svelte'; import ChangelogModal from '$lib/components/ChangelogModal.svelte'; import UpdateToast from '$lib/components/UpdateToast.svelte'; - import { Toaster } from 'svelte-sonner'; + import { Toaster, toast } from 'svelte-sonner'; import { activeTab, currentWorkspace, @@ -49,6 +49,9 @@ let showSettings = $state(false); let showUpdateToast = $state(false); let showSetup = $state(false); + let connectionToast: string | number | undefined; + let applyingServiceWorkerUpdate = false; + const BROWSER_SW_CLEANUP_RELOAD = 'cptr:pwa:browser-sw-cleanup-reload'; // Auth state type AuthState = 'checking' | 'needs_setup' | 'needs_login' | 'authenticated'; @@ -94,12 +97,23 @@ // iOS may fire 'scroll' instead of 'resize' when keyboard opens. vv?.addEventListener('resize', syncKeyboardInset); vv?.addEventListener('scroll', syncKeyboardInset); + + if (isInstalledPwa()) { + registerServiceWorker().catch(() => {}); + } else { + cleanBrowserServiceWorker().catch(() => {}); + } + window.addEventListener('offline', showOfflineToast); + window.addEventListener('online', showOnlineToast); + return () => { clearInterval(healthCheck); document.documentElement.style.removeProperty('--keyboard-inset-bottom'); window.removeEventListener('resize', syncKeyboardInset); vv?.removeEventListener('resize', syncKeyboardInset); vv?.removeEventListener('scroll', syncKeyboardInset); + window.removeEventListener('offline', showOfflineToast); + window.removeEventListener('online', showOnlineToast); }; }); @@ -232,6 +246,84 @@ } } + function isInstalledPwa() { + const nav = navigator as Navigator & { standalone?: boolean }; + return ( + nav.standalone === true || + window.matchMedia('(display-mode: standalone)').matches || + window.matchMedia('(display-mode: window-controls-overlay)').matches + ); + } + + function showOfflineToast() { + if (connectionToast) return; + connectionToast = toast.error($t('pwa.unreachable'), { duration: Infinity }); + } + + function showOnlineToast() { + if (connectionToast) toast.dismiss(connectionToast); + connectionToast = undefined; + toast.success($t('pwa.connectionRestored')); + } + + async function clearCptrCaches() { + if (!('caches' in window)) return; + const keys = await caches.keys(); + await Promise.all(keys.filter((key) => key.startsWith('cptr-')).map((key) => caches.delete(key))); + } + + function isCptrWorker(registration: ServiceWorkerRegistration) { + const script = + registration.active?.scriptURL || + registration.waiting?.scriptURL || + registration.installing?.scriptURL || + ''; + return script.endsWith('/service-worker.js'); + } + + async function cleanBrowserServiceWorker() { + if (!('serviceWorker' in navigator)) return; + const registrations = await navigator.serviceWorker.getRegistrations(); + const cptrRegistrations = registrations.filter(isCptrWorker); + if (!cptrRegistrations.length) { + sessionStorage.removeItem(BROWSER_SW_CLEANUP_RELOAD); + return; + } + + const hadController = !!navigator.serviceWorker.controller; + await Promise.all(cptrRegistrations.map((registration) => registration.unregister())); + await clearCptrCaches(); + if (hadController && !sessionStorage.getItem(BROWSER_SW_CLEANUP_RELOAD)) { + sessionStorage.setItem(BROWSER_SW_CLEANUP_RELOAD, '1'); + location.reload(); + } + } + + async function registerServiceWorker() { + if (!('serviceWorker' in navigator)) return; + const registration = await navigator.serviceWorker.register('/service-worker.js'); + if (registration.waiting && navigator.serviceWorker.controller) { + applyServiceWorkerUpdate(registration); + } + registration.addEventListener('updatefound', () => { + const worker = registration.installing; + if (!worker) return; + worker.addEventListener('statechange', () => { + if (worker.state === 'installed' && navigator.serviceWorker.controller) { + applyServiceWorkerUpdate(registration); + } + }); + }); + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (applyingServiceWorkerUpdate) window.location.reload(); + }); + } + + function applyServiceWorkerUpdate(registration: ServiceWorkerRegistration) { + applyingServiceWorkerUpdate = true; + registration.waiting?.postMessage({ type: 'SKIP_WAITING' }); + } + function handleKeydown(e: KeyboardEvent) { const action = matchKeybinding(e); if (!action) return; @@ -281,6 +373,15 @@ gitStatusStore.setRoot(ws.path); }); + // Keep git decorations fresh after filesystem changes. + $effect(() => { + const _tick = systemEvents.fsTick; + const ws = $currentWorkspace; + if (_tick > 0 && ws && gitStatusStore.isRepo && systemEvents.isRelevantFsChange(ws.path)) { + gitStatusStore.refresh({ force: true }); + } + }); + // Sync isGitRepo flag from centralized store $effect(() => { isGitRepo.set(gitStatusStore.isRepo); diff --git a/cptr/frontend/src/routes/+page.svelte b/cptr/frontend/src/routes/+page.svelte index 51b8b5ba..3694cd5a 100644 --- a/cptr/frontend/src/routes/+page.svelte +++ b/cptr/frontend/src/routes/+page.svelte @@ -3,29 +3,31 @@ import { goto } from '$app/navigation'; import { currentWorkspace, - activeTab, workspaceList, addWorkspace, loadWorkspace, gitReviewOpen, setActiveGroup, setSplitRatio, - moveTabToGroup, - openInSplit, openTabInSplit, setSplitDirection, openChatTab, openFileTab, + openTerminalTab, setFileBrowserCwd, appVersion, - showChangelog + showChangelog, + showSearch, + pwaPreferences } from '$lib/stores'; - import { splitActive } from '$lib/stores'; - import type { Tab, EditorGroup, WorkspaceState } from '$lib/stores'; + import type { Tab, EditorGroup } from '$lib/stores'; import { t } from '$lib/i18n'; import { get } from 'svelte/store'; import { getWelcome } from '$lib/apis/state'; import { createSession } from '$lib/apis/terminal'; + import { createEntry, writeFile, uploadFiles as uploadFilesApi } from '$lib/apis/files'; + import { deleteSharePayload, getSharePayload } from '$lib/intents/payloadStore'; + import type { LaunchIntent, ShareBehavior, SharePayload } from '$lib/intents/types'; import FileBrowser from '$lib/components/FileBrowser.svelte'; import FileEditor from '$lib/components/FileEditor.svelte'; import GitView from '$lib/components/GitView.svelte'; @@ -35,10 +37,26 @@ import DirectoryPicker from '$lib/components/DirectoryPicker.svelte'; import GroupTabBar from '$lib/components/GroupTabBar.svelte'; import Icon from '$lib/components/Icon.svelte'; + import WorkspacePicker from '$lib/components/WorkspacePicker.svelte'; import SystemInfo from '$lib/components/SystemInfo.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; let showPicker = $state(false); + let pendingIntent = $state(null); + let folderPickerIntent = $state(null); + let folderPickerWorkspace = $state(null); + const INTENT_URL_KEYS = [ + 'intent', + 'chatId', + 'file', + 'dir', + 'targetDir', + 'payload', + 'shareBehavior', + 'title', + 'text', + 'url' + ]; // โ”€โ”€ URL-driven workspace loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // The workspace path comes from the URL query param: ?workspace=/path/to/dir @@ -46,38 +64,255 @@ // Intent params (chatId, file, dir) are processed AFTER loading. let lastLoadedPath = $state(null); + type LaunchQueueWindow = Window & { + __cptrLaunchQueueBound?: boolean; + launchQueue?: { + setConsumer: (consumer: (params: { files?: { getFile: () => Promise }[] }) => void) => void; + }; + }; + + function urlIntent(url: URL): LaunchIntent | null { + const protocol = webCptrIntent(url.searchParams.get('intent')); + const params = protocol?.params ?? url.searchParams; + const intent = protocol?.intent ?? url.searchParams.get('intent'); + const workspace = params.get('workspace') || undefined; + const chatId = params.get('chatId'); + const filePath = params.get('file'); + const dirPath = params.get('dir'); + + if (intent === 'newNote') return { kind: 'newNote', workspace }; + if (intent === 'newChat') return { kind: 'newChat', workspace }; + if (intent === 'newTerminal') return { kind: 'newTerminal', workspace }; + if (intent === 'openWorkspace') return { kind: 'openWorkspace' }; + if (intent === 'search') return { kind: 'search', workspace }; + if (intent === 'importFiles') { + return { kind: 'importFiles', workspace, targetDir: params.get('targetDir') || undefined }; + } + if (intent === 'share') { + return { + kind: 'share', + workspace, + payloadId: params.get('payload') || undefined, + targetDir: params.get('targetDir') || undefined, + shareBehavior: (params.get('shareBehavior') || undefined) as ShareBehavior | undefined, + share: { + title: params.get('title') || undefined, + text: params.get('text') || undefined, + url: params.get('url') || undefined + } + }; + } + if (chatId !== null) return { kind: 'openChat', workspace, chatId: chatId || null }; + if (filePath) return { kind: 'openFile', workspace, filePath }; + if (dirPath) return { kind: 'openDir', workspace, dirPath }; + return null; + } + + function webCptrIntent(raw: string | null): { intent: string | null; params: URLSearchParams } | null { + if (!raw) return null; + let decoded = raw; + try { + decoded = decodeURIComponent(raw); + } catch {} + if (!decoded.startsWith('web+cptr:')) return null; + try { + const parsed = new URL(decoded); + return { + intent: + parsed.searchParams.get('intent') || + parsed.hostname || + parsed.pathname.replace(/^\/+/, '') || + null, + params: parsed.searchParams + }; + } catch { + const rest = decoded.replace(/^web\+cptr:(\/\/)?/, ''); + const [intentPart, query = ''] = rest.split('?'); + return { intent: intentPart.replace(/^\/+/, '') || null, params: new URLSearchParams(query) }; + } + } + + function clearIntentUrl(url: URL): string { + const next = new URL(url); + for (const key of INTENT_URL_KEYS) next.searchParams.delete(key); + return next.pathname + next.search; + } - function processIntentParams() { + function noteBaseName(now = new Date()): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `note-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad( + now.getHours() + )}${pad(now.getMinutes())}`; + } + + function shareMarkdown(payload: SharePayload): string { + const lines: string[] = []; + if (payload.title) lines.push(`# ${payload.title.trim()}`, ''); + if (payload.text) lines.push(payload.text.trim(), ''); + if (payload.url) lines.push(payload.url.trim(), ''); + if (payload.files?.length) { + lines.push('Files:', ...payload.files.map((file) => `- ${file.name}`), ''); + } + return lines.join('\n').trimEnd() + '\n'; + } + + async function processIntentParams() { const url = new URL($page.url); - const chatId = url.searchParams.get('chatId'); - const filePath = url.searchParams.get('file'); - const dirPath = url.searchParams.get('dir'); - - let dirty = false; - if (url.searchParams.has('chatId')) { - if (chatId) { - openChatTab(chatId); - } else { + const parsed = urlIntent(url); + if (parsed) history.replaceState(history.state, '', clearIntentUrl(url)); + if (!parsed) return; + await handleIntent(parsed); + } + + async function handleIntent(intent: LaunchIntent) { + if (intent.kind === 'openWorkspace') { + showPicker = true; + return; + } + + const targetWorkspace = intent.workspace; + const needsWorkspace = intentNeedsExplicitWorkspace(intent.kind); + if (needsWorkspace && !targetWorkspace) { + pendingIntent = intent; + return; + } + + switch (intent.kind) { + case 'newNote': + await createNote(targetWorkspace!); + break; + case 'newChat': openChatTab(); + break; + case 'newTerminal': + await openTerminalTab(); + break; + case 'search': + showSearch.set(true); + break; + case 'openChat': + openChatTab(intent.chatId ?? undefined); + break; + case 'openFile': + if (intent.filePath) openFileTab(intent.filePath); + break; + case 'openDir': + if (intent.dirPath) setFileBrowserCwd(intent.dirPath); + break; + case 'share': + await handleShareIntent(intent, targetWorkspace!); + break; + case 'importFiles': + await importIntentFiles(intent, targetWorkspace!); + break; + } + } + + function intentNeedsExplicitWorkspace(kind: LaunchIntent['kind']): boolean { + return !['openWorkspace', 'search'].includes(kind); + } + + async function createNote(workspacePath: string, content = '') { + if (get(currentWorkspace)?.path !== workspacePath) { + await loadWorkspace(workspacePath); + } + + const base = noteBaseName(); + for (let i = 1; i < 100; i += 1) { + const suffix = i === 1 ? '' : `-${i}`; + const path = `${workspacePath}/${base}${suffix}.md`; + try { + await createEntry(path, 'file'); + if (content) await writeFile(path, content); + openFileTab(path, undefined, { edit: true }); + return path; + } catch (e: any) { + if (e?.status !== 409) throw e; } - url.searchParams.delete('chatId'); - dirty = true; } - if (filePath) { - openFileTab(filePath); - url.searchParams.delete('file'); - dirty = true; + throw new Error('Could not create note'); + } + + async function handleShareIntent(intent: LaunchIntent, workspacePath: string) { + const behavior = intent.shareBehavior ?? get(pwaPreferences).shareBehavior; + if (behavior === 'ask') { + pendingIntent = intent; + return; } - if (dirPath) { - setFileBrowserCwd(dirPath); - url.searchParams.delete('dir'); - dirty = true; + + let share = intent.share; + if (intent.payloadId) { + share = (await getSharePayload(intent.payloadId)) ?? share; + await deleteSharePayload(intent.payloadId).catch(() => {}); + } + if (!share) return; + + if (behavior === 'chatDraft') { + sessionStorage.setItem(`cptr:intent:chatDraft:${workspacePath}`, shareMarkdown(share)); + openChatTab(); + return; + } + await createNote(workspacePath, shareMarkdown(share)); + } + + async function importIntentFiles(intent: LaunchIntent, workspacePath: string) { + const files = intent.importFiles?.files; + if (!files?.length) return; + const prefs = get(pwaPreferences); + if (prefs.importDestination === 'askFolder' && !intent.targetDir) { + folderPickerIntent = intent; + folderPickerWorkspace = workspacePath; + showPicker = true; + return; } - if (dirty) { - history.replaceState(history.state, '', url.pathname + url.search); + const directory = resolveImportDirectory(workspacePath, intent.targetDir); + for (const file of files) { + const form = new FormData(); + form.append('file', file, file.name); + form.append('directory', directory); + await uploadFilesApi(directory, form); } } + function resolveImportDirectory(workspacePath: string, targetDir?: string): string { + if (targetDir) return targetDir; + const prefs = get(pwaPreferences); + if (prefs.importDestination !== 'configuredFolder' || !prefs.importFolder?.trim()) { + return workspacePath; + } + const folder = prefs.importFolder.trim(); + if (folder.startsWith('/')) return folder; + return `${workspacePath}/${folder.replace(/^\/+/, '')}`; + } + + async function chooseIntentWorkspace(path: string, behavior?: ShareBehavior) { + if (!pendingIntent) return; + addWorkspace(path); + const intent: LaunchIntent = { + ...pendingIntent, + workspace: path, + shareBehavior: behavior ?? pendingIntent.shareBehavior + }; + pendingIntent = null; + const params = new URLSearchParams({ workspace: path }); + await goto(`/?${params.toString()}`, { replaceState: true }); + if (get(currentWorkspace)?.path !== path) await loadWorkspace(path); + await handleIntent(intent); + } + + function handlePickedImportFolder(path: string) { + if (!folderPickerIntent || !folderPickerWorkspace) return; + const intent: LaunchIntent = { + ...folderPickerIntent, + workspace: folderPickerWorkspace, + targetDir: path + }; + folderPickerIntent = null; + folderPickerWorkspace = null; + showPicker = false; + void handleIntent(intent); + } + $effect(() => { const wsPath = $page.url.searchParams.get('workspace'); if (wsPath && wsPath !== lastLoadedPath) { @@ -90,9 +325,28 @@ } else if (!wsPath) { lastLoadedPath = null; currentWorkspace.set(null); + processIntentParams(); } }); + $effect(() => { + if (typeof window === 'undefined') return; + const w = window as LaunchQueueWindow; + if (!w.launchQueue || w.__cptrLaunchQueueBound) return; + w.__cptrLaunchQueueBound = true; + w.launchQueue.setConsumer(async (launchParams) => { + const handles = launchParams.files ?? []; + if (!handles.length) return; + const files: File[] = []; + for (const handle of handles) { + try { + files.push(await handle.getFile()); + } catch {} + } + if (files.length) await handleIntent({ kind: 'importFiles', importFiles: { files } }); + }); + }); + // Welcome page data let welcomeData = $state<{ hostname: string; @@ -123,7 +377,7 @@ if (!$currentWorkspace) { getWelcome() .then((data) => { - welcomeData = data; + welcomeData = data as typeof welcomeData; }) .catch(() => {}); } @@ -166,7 +420,10 @@ function quickOpen(path: string) { addWorkspace(path); - goto(`/?workspace=${encodeURIComponent(path)}`); + // Carry forward any pending intent params through workspace selection + const params = new URLSearchParams($page.url.searchParams); + params.set('workspace', path); + goto(`/?${params.toString()}`); } function shortenPath(path: string): string { @@ -388,10 +645,6 @@ {/if}
- - {#if showPicker} - (showPicker = false)} /> - {/if} {:else}
{#each group.tabs.filter((t) => t.type === 'file' && t.filePath) as tab (tab.id)}
- +
{/each} @@ -463,13 +720,13 @@ {#each group.tabs.filter((t) => t.type === 'terminal' && t.sessionId) as tab (tab.id)}
- +
{/each} {#each group.tabs.filter((t) => t.type === 'preview' && t.port) as tab (tab.id)}
- +
{/each} @@ -497,6 +754,26 @@
{/if} +{#if pendingIntent} + (pendingIntent = null)} + /> +{/if} + +{#if showPicker} + { + showPicker = false; + folderPickerIntent = null; + folderPickerWorkspace = null; + }} + onselect={folderPickerIntent ? handlePickedImportFolder : undefined} + /> +{/if} + + + +
+

Computer is unreachable

+

Reconnect and refresh before continuing.

+
+ +
+
+ + diff --git a/cptr/frontend/svelte.config.js b/cptr/frontend/svelte.config.js index b3b69afb..fd81bf84 100644 --- a/cptr/frontend/svelte.config.js +++ b/cptr/frontend/svelte.config.js @@ -12,6 +12,9 @@ const config = { }), paths: { relative: false + }, + serviceWorker: { + register: false } } }; diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index 6cb90d86..26e05d61 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -251,7 +251,11 @@ async def _fetch_provider_models(conn: dict) -> list[str]: @router.get("/{chat_id}") -async def get_chat(chat_id: str, request: Request): +async def get_chat( + chat_id: str, + request: Request, + model_id: str | None = Query(None, description="Model used for system prompt context"), +): """Get chat metadata + all messages.""" user_id = _get_user(request) chat = await Chat.get_by_id(chat_id) @@ -259,6 +263,7 @@ async def get_chat(chat_id: str, request: Request): raise HTTPException(404, "chat not found") messages = await ChatMessage.get_all_by_chat(chat_id) + context_usage = await _get_chat_context_usage(chat, model_id) return { "chat": { "id": chat.id, @@ -270,6 +275,7 @@ async def get_chat(chat_id: str, request: Request): "updated_at": chat.updated_at, }, "messages": [_message_dict(m) for m in messages], + "context_usage": context_usage, } @@ -297,6 +303,77 @@ def _message_dict(m) -> dict: return d +async def _get_context_leaf_message_id(chat) -> str | None: + if chat.current_message_id: + return chat.current_message_id + messages = await ChatMessage.get_all_by_chat(chat.id) + return messages[-1].id if messages else None + + +async def _get_chat_context_usage(chat, model_id: str | None = None) -> dict | None: + message_id = await _get_context_leaf_message_id(chat) + if not message_id: + return None + + from cptr.utils.chat_task import _load_message_history, _load_system_prompt + from cptr.utils.context import ( + build_context_usage, + estimate_context_usage, + estimate_messages_tokens, + ) + + messages, existing_summary = await _load_message_history(chat.id, message_id) + workspace = (chat.meta or {}).get("workspace", "") + model = model_id or await _infer_chat_model(chat.id) + system = await _load_system_prompt(workspace, model or "") + if existing_summary: + system += f"\n\n[CONVERSATION SUMMARY]\n{existing_summary}" + + usage_checkpoint = await _get_latest_usage_checkpoint(chat.id, message_id) + if usage_checkpoint: + trailing_messages, usage = usage_checkpoint + tokens = usage.get("input_tokens") + if isinstance(tokens, int) and tokens > 0: + tokens += usage.get("output_tokens", 0) + if trailing_messages: + tokens += estimate_messages_tokens( + [{"role": m.role, "content": m.content or ""} for m in trailing_messages] + ) + return build_context_usage(tokens, source="estimated") + + return estimate_context_usage(messages, system) + + +async def _infer_chat_model(chat_id: str) -> str: + messages = await ChatMessage.get_all_by_chat(chat_id) + for message in reversed(messages): + if message.model: + return message.model + return await Config.get("chat.default_model") or "" + + +async def _get_latest_usage_checkpoint( + chat_id: str, message_id: str +) -> tuple[list[ChatMessage], dict] | None: + all_msgs = await ChatMessage.get_all_by_chat(chat_id) + msg_map = {m.id: m for m in all_msgs} + chain = [] + cur = msg_map.get(message_id) + while cur: + chain.append(cur) + cur = msg_map.get(cur.parent_id) if cur.parent_id else None + chain.reverse() + + for index in range(len(chain) - 1, -1, -1): + message = chain[index] + if message.chat_summary: + return None + usage = message.usage or {} + if message.role == "assistant" and usage.get("input_tokens"): + return chain[index + 1 :], usage + return None + + # โ”€โ”€ Delete a chat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -332,6 +409,10 @@ class SendMessageRequest(BaseModel): params: dict = {} +class CompactRequest(BaseModel): + model_id: str + + @router.post("") async def send_message(body: SendMessageRequest, request: Request): """Send a message. Omit chat_id to create a new chat. @@ -451,6 +532,69 @@ async def send_message(body: SendMessageRequest, request: Request): return resp +# โ”€โ”€ Manual context compaction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@router.post("/{chat_id}/compact") +async def compact_chat(chat_id: str, body: CompactRequest, request: Request): + """Summarize older active-branch messages and store a compaction checkpoint.""" + user_id = _get_user(request) + chat = await Chat.get_by_id(chat_id) + if not chat or chat.user_id != user_id: + raise HTTPException(404, "chat not found") + if await _chat_has_active_generation(chat_id): + raise HTTPException(409, "wait for the current response to finish before compacting") + + message_id = await _get_context_leaf_message_id(chat) + if not message_id: + return {"ok": True, "compacted": False, "reason": "empty", "context_usage": None} + current_msg = await ChatMessage.get_by_id(message_id) + if not current_msg or not current_msg.parent_id: + usage = await _get_chat_context_usage(chat, body.model_id) + return {"ok": True, "compacted": False, "reason": "too_short", "context_usage": usage} + + connection, bare_model = await _resolve_connection(body.model_id, request.app.state) + + from cptr.utils.chat_task import _load_message_history + from cptr.utils.summarize import summarize_messages + + messages, existing_summary = await _load_message_history(chat_id, current_msg.parent_id) + if not messages: + usage = await _get_chat_context_usage(chat, body.model_id) + return {"ok": True, "compacted": False, "reason": "too_short", "context_usage": usage} + + provider = connection["provider"] + api_key = decrypt_key(connection.get("api_key", ""), _get_jwt_secret()) + from cptr.utils.chat_task import _default_base_url + + base_url = connection.get("base_url") or _default_base_url(provider) + api_type = connection.get("api_type", "chat_completions") + + summary = await summarize_messages( + messages, + existing_summary, + provider, + base_url, + api_key, + bare_model, + api_type=api_type, + ) + await ChatMessage.update(message_id, chat_summary=summary) + + from cptr.utils.chat_export import export_chat_to_file + + await export_chat_to_file(chat_id) + usage = await _get_chat_context_usage(chat, body.model_id) + return { + "ok": True, + "compacted": True, + "dropped_messages": len(messages), + "kept_messages": 1, + "summary_chars": len(summary), + "context_usage": usage, + } + + # โ”€โ”€ Approve / reject a pending tool call โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/cptr/routers/workspace.py b/cptr/routers/workspace.py index 7bfbec59..60e5ea41 100644 --- a/cptr/routers/workspace.py +++ b/cptr/routers/workspace.py @@ -519,7 +519,7 @@ async def upload_file( if not target_dir.is_dir(): raise HTTPException(status_code=400, detail=f"Not a directory: {directory}") - target = target_dir / file.filename + target = _unique_child_path(target_dir, file.filename or "file") try: content = await file.read() await asyncio.to_thread(target.write_bytes, content) @@ -529,6 +529,21 @@ async def upload_file( return {"status": "uploaded", "path": str(target), "size": len(content)} +def _unique_child_path(directory: Path, filename: str) -> Path: + """Return a non-existing child path by appending -2, -3, ... on collisions.""" + safe_name = Path(filename).name or "file" + target = directory / safe_name + if not target.exists(): + return target + stem = target.stem + suffix = target.suffix + for i in range(2, 10_000): + candidate = directory / f"{stem}-{i}{suffix}" + if not candidate.exists(): + return candidate + raise HTTPException(status_code=409, detail=f"Could not find available name for: {filename}") + + # โ”€โ”€ File viewing (inline) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ import mimetypes diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index a5e112a0..7eb2688b 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -325,10 +325,7 @@ def _load_instruction_files(workspace: str, max_bytes: int = 32_000) -> str: DEFAULT_SYSTEM_PROMPT = ( "You are a helpful coding assistant. " "You have access to tools to read, search, and modify files in the workspace. " - "Use them to help the user with their coding tasks.\n\n" - "For complex tasks, create an implementation plan first using " - "create_artifact. Then wait for an explicit approval message before using " - "tools or implementing." + "Use them to help the user with their coding tasks." "\n\n{{INSTRUCTIONS}}" "\n\n{{SKILLS}}" "\n\nWorkspace: {{WORKSPACE_NAME}}" diff --git a/cptr/utils/context.py b/cptr/utils/context.py index c0d8acab..269d58f7 100644 --- a/cptr/utils/context.py +++ b/cptr/utils/context.py @@ -1,8 +1,4 @@ -"""Context estimation for chat compaction. - -Uses a character-based heuristic (len/4) to estimate token counts. -A follow-up will add real usage data from API responses for precision. -""" +"""Context usage helpers for chat compaction.""" from __future__ import annotations @@ -62,6 +58,26 @@ def should_compact( return total > threshold +def build_context_usage(tokens: int, *, threshold: int | None = None, source: str) -> dict: + """Return context fullness stats for estimated token counts.""" + resolved_threshold = threshold or _get_threshold() + percent = round((tokens / resolved_threshold) * 100) if resolved_threshold > 0 else 0 + return { + "tokens": tokens, + "estimated_tokens": tokens, + "threshold": resolved_threshold, + "percent": max(0, percent), + "source": source, + } + + +def estimate_context_usage(messages: list[dict], system_prompt: str) -> dict: + """Return context fullness stats using the same estimate as compaction.""" + threshold = _get_threshold() + estimated_tokens = estimate_tokens(system_prompt) + estimate_messages_tokens(messages) + return build_context_usage(estimated_tokens, threshold=threshold, source="estimated") + + def _get_threshold() -> int: """Read threshold: app_config (admin UI) > config.toml [chat] > env var/default.""" try: diff --git a/cptr/utils/web/perplexity.py b/cptr/utils/web/perplexity.py index cda0aed2..04411841 100644 --- a/cptr/utils/web/perplexity.py +++ b/cptr/utils/web/perplexity.py @@ -9,11 +9,16 @@ import httpx -async def search(query: str, api_key: str, count: int = 5) -> str: +async def search( + query: str, + api_key: str, + count: int = 5, + base_url: str = "https://api.perplexity.ai", +) -> str: """Search using Perplexity's dedicated search API.""" async with httpx.AsyncClient(timeout=10) as client: resp = await client.post( - "https://api.perplexity.ai/search", + f"{base_url.rstrip('/')}/search", json={"query": query}, headers={ "Authorization": f"Bearer {api_key}", diff --git a/cptr/utils/web/search.py b/cptr/utils/web/search.py index 9ddcfed9..8f31d9ee 100644 --- a/cptr/utils/web/search.py +++ b/cptr/utils/web/search.py @@ -2,7 +2,7 @@ Priority in auto mode: 1. Exa (EXA_API_KEY or web.exa_api_key) -2. Perplexity (PERPLEXITY_API_KEY or web.perplexity_api_key) +2. Perplexity (PERPLEXITY_API_KEY or web.perplexity_api_key, optional PERPLEXITY_BASE_URL or web.perplexity_base_url) 3. Tavily (TAVILY_API_KEY or web.tavily_api_key) 4. Brave (BRAVE_API_KEY or web.brave_api_key) 5. DuckDuckGo (zero-config fallback) @@ -52,6 +52,9 @@ async def web_search_handler(query: str) -> str: exa_key = await _get_key("EXA_API_KEY", "web.exa_api_key") perplexity_key = await _get_key("PERPLEXITY_API_KEY", "web.perplexity_api_key") + perplexity_url = ( + await _get_config("web.perplexity_base_url") + ) or os.environ.get("PERPLEXITY_BASE_URL", "") tavily_key = await _get_key("TAVILY_API_KEY", "web.tavily_api_key") brave_key = await _get_key("BRAVE_API_KEY", "web.brave_api_key") cc_key = await _get_key("CHAT_COMPLETIONS_SEARCH_API_KEY", "web.chat_completions_api_key") @@ -72,7 +75,7 @@ async def web_search_handler(query: str) -> str: elif provider == "perplexity": if not perplexity_key: return "Error: Perplexity API key not configured." - return await perplexity.search(query, perplexity_key) + return await perplexity.search(query, perplexity_key, base_url=perplexity_url) if perplexity_url else await perplexity.search(query, perplexity_key) elif provider == "tavily": if not tavily_key: return "Error: Tavily API key not configured." @@ -98,7 +101,8 @@ async def web_search_handler(query: str) -> str: if exa_key: providers.append(("exa", lambda: exa.search(query, exa_key))) if perplexity_key: - providers.append(("perplexity", lambda: perplexity.search(query, perplexity_key))) + _pplx_kw = {"base_url": perplexity_url} if perplexity_url else {} + providers.append(("perplexity", lambda: perplexity.search(query, perplexity_key, **_pplx_kw))) if tavily_key: providers.append(("tavily", lambda: tavily.search(query, tavily_key))) if brave_key: diff --git a/pyproject.toml b/pyproject.toml index be0a8eda..378f8934 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.5.3" +version = "0.5.4" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md"