From 0fddcba26d0fa732eae16727477539b4588e4934 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 20 Jun 2026 01:44:32 +0200 Subject: [PATCH 1/3] refac --- .../src/lib/components/Admin/Web.svelte | 20 ++++++ cptr/frontend/src/lib/i18n/locales/en.json | 3 + cptr/utils/web/firecrawl.py | 67 +++++++++++++++++++ cptr/utils/web/search.py | 58 ++++++++++++---- 4 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 cptr/utils/web/firecrawl.py diff --git a/cptr/frontend/src/lib/components/Admin/Web.svelte b/cptr/frontend/src/lib/components/Admin/Web.svelte index 030bba3..5b14981 100644 --- a/cptr/frontend/src/lib/components/Admin/Web.svelte +++ b/cptr/frontend/src/lib/components/Admin/Web.svelte @@ -19,6 +19,8 @@ let braveKey = $state(''); let perplexityKey = $state(''); let perplexityBaseUrl = $state(''); + let firecrawlSearchKey = $state(''); + let firecrawlSearchBaseUrl = $state('https://api.firecrawl.dev'); let ccKey = $state(''); let ccBaseUrl = $state(''); let ccModel = $state(''); @@ -46,6 +48,8 @@ braveKey = (config['web.brave_api_key'] as string) || ''; perplexityKey = (config['web.perplexity_api_key'] as string) || ''; perplexityBaseUrl = (config['web.perplexity_base_url'] as string) || ''; + firecrawlSearchKey = (config['web.firecrawl_api_key'] as string) || ''; + firecrawlSearchBaseUrl = (config['web.firecrawl_base_url'] as string) || 'https://api.firecrawl.dev'; ccKey = (config['web.chat_completions_api_key'] as string) || ''; ccBaseUrl = (config['web.chat_completions_base_url'] as string) || ''; ccModel = (config['web.chat_completions_model'] as string) || ''; @@ -77,6 +81,8 @@ 'web.brave_api_key': braveKey, 'web.perplexity_api_key': perplexityKey, 'web.perplexity_base_url': perplexityBaseUrl, + 'web.firecrawl_api_key': firecrawlSearchKey, + 'web.firecrawl_base_url': firecrawlSearchBaseUrl, 'web.chat_completions_api_key': ccKey, 'web.chat_completions_base_url': ccBaseUrl, 'web.chat_completions_model': ccModel, @@ -147,6 +153,7 @@ + @@ -181,6 +188,19 @@ 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" />

{$t('admin.webBraveHint')}

+ {:else if searchProvider === 'firecrawl'} +
+ + +

{$t('admin.webFirecrawlHint')}

+
+
+ + +

{$t('admin.browserFirecrawlBaseUrlHint')}

+
{:else if searchProvider === 'perplexity'}
diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index b3f4b82..37fe401 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -304,6 +304,9 @@ "admin.webTavilyHint": "Get your API key at tavily.com", "admin.webBraveKey": "Brave API Key", "admin.webBraveHint": "Get your API key at brave.com/search/api", + "admin.webFirecrawlKey": "Firecrawl API Key", + "admin.webFirecrawlBaseUrl": "Firecrawl Base URL", + "admin.webFirecrawlHint": "Uses Firecrawl search. Leave base URL as-is unless you self-host Firecrawl.", "admin.webDuckDuckGoNote": "DuckDuckGo requires no API key (fallback).", "admin.webPerplexityKey": "Perplexity API Key", "admin.webPerplexityHint": "Get your API key at perplexity.ai", diff --git a/cptr/utils/web/firecrawl.py b/cptr/utils/web/firecrawl.py new file mode 100644 index 0000000..82683b6 --- /dev/null +++ b/cptr/utils/web/firecrawl.py @@ -0,0 +1,67 @@ +"""Firecrawl search provider. + +https://docs.firecrawl.dev/api-reference/endpoint/search +Uses the v2 search endpoint and returns web results formatted for LLM context. +""" + +from __future__ import annotations + +import httpx + +DEFAULT_BASE_URL = "https://api.firecrawl.dev" + + +async def search( + query: str, + api_key: str, + count: int = 5, + base_url: str = DEFAULT_BASE_URL, +) -> str: + """Search using Firecrawl's search API.""" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{base_url.rstrip('/')}/v2/search", + json={ + "query": query, + "limit": count, + "sources": ["web"], + }, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + ) + resp.raise_for_status() + data = resp.json() + + if not data.get("success", True): + error = data.get("error", "Unknown error") + return f"Firecrawl error: {error}" + + results = [] + for item in data.get("data", {}).get("web", [])[:count]: + title = item.get("title", "") + url = item.get("url", "") + description = item.get("description") or item.get("snippet", "") + markdown = item.get("markdown", "") + + parts = [] + if title: + parts.append(f"**{title}**") + if url: + parts.append(url) + if description: + parts.append(description) + if markdown: + if len(markdown) > 1500: + markdown = markdown[:1500] + "..." + parts.append(markdown) + + if parts: + results.append("\n".join(parts)) + + warning = data.get("warning") + if warning and results: + results.append(f"Warning: {warning}") + + return "\n\n".join(results) if results else "No results found." diff --git a/cptr/utils/web/search.py b/cptr/utils/web/search.py index 8f31d9e..2965856 100644 --- a/cptr/utils/web/search.py +++ b/cptr/utils/web/search.py @@ -5,7 +5,8 @@ 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) +5. Firecrawl (FIRECRAWL_API_KEY or web.firecrawl_api_key) +6. DuckDuckGo (zero-config fallback) """ from __future__ import annotations @@ -41,7 +42,15 @@ async def _get_config(key: str) -> str: async def web_search_handler(query: str) -> str: """Search the web using the configured or best available provider.""" - from cptr.utils.web import exa, perplexity, tavily, brave, duckduckgo, chat_completions + from cptr.utils.web import ( + exa, + perplexity, + tavily, + brave, + duckduckgo, + chat_completions, + firecrawl, + ) # Check if web access is disabled by admin enabled = await _get_config("web.enabled") @@ -52,18 +61,26 @@ 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", "") + 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") + firecrawl_key = await _get_key("FIRECRAWL_API_KEY", "web.firecrawl_api_key") + if not firecrawl_key: + firecrawl_key = await _get_config("browser.firecrawl_api_key") + firecrawl_url = (await _get_config("web.firecrawl_base_url")) or os.environ.get( + "FIRECRAWL_BASE_URL", "" + ) + if not firecrawl_url: + firecrawl_url = await _get_config("browser.firecrawl_base_url") cc_key = await _get_key("CHAT_COMPLETIONS_SEARCH_API_KEY", "web.chat_completions_api_key") - cc_url = ( - await _get_config("web.chat_completions_base_url") - ) or os.environ.get("CHAT_COMPLETIONS_SEARCH_BASE_URL", "") - cc_model = ( - await _get_config("web.chat_completions_model") - ) or os.environ.get("CHAT_COMPLETIONS_SEARCH_MODEL", "") + cc_url = (await _get_config("web.chat_completions_base_url")) or os.environ.get( + "CHAT_COMPLETIONS_SEARCH_BASE_URL", "" + ) + cc_model = (await _get_config("web.chat_completions_model")) or os.environ.get( + "CHAT_COMPLETIONS_SEARCH_MODEL", "" + ) # Explicit provider mode if provider != "auto": @@ -75,7 +92,11 @@ 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, base_url=perplexity_url) if perplexity_url else 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." @@ -84,6 +105,12 @@ async def web_search_handler(query: str) -> str: if not brave_key: return "Error: Brave API key not configured." return await brave.search(query, brave_key) + elif provider == "firecrawl": + if not firecrawl_key: + return "Error: Firecrawl API key not configured." + if firecrawl_url: + return await firecrawl.search(query, firecrawl_key, base_url=firecrawl_url) + return await firecrawl.search(query, firecrawl_key) elif provider == "duckduckgo": return await duckduckgo.search(query) elif provider == "chat_completions": @@ -102,11 +129,16 @@ async def web_search_handler(query: str) -> str: providers.append(("exa", lambda: exa.search(query, exa_key))) if perplexity_key: _pplx_kw = {"base_url": perplexity_url} if perplexity_url else {} - providers.append(("perplexity", lambda: perplexity.search(query, perplexity_key, **_pplx_kw))) + 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: providers.append(("brave", lambda: brave.search(query, brave_key))) + if firecrawl_key: + _fc_kw = {"base_url": firecrawl_url} if firecrawl_url else {} + providers.append(("firecrawl", lambda: firecrawl.search(query, firecrawl_key, **_fc_kw))) providers.append(("duckduckgo", lambda: duckduckgo.search(query))) for name, fn in providers: From 07e7b7623b9a208883df075b5439e925b56b2d18 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 20 Jun 2026 18:06:00 +0200 Subject: [PATCH 2/3] refac --- .../components/Admin/CreateBotModal.svelte | 77 ++++++--- .../src/lib/components/SearchModal.svelte | 74 ++++++--- .../automations/AutomationModal.svelte | 51 ++++-- .../automations/AutomationsPanel.svelte | 155 +++++++++++++----- .../src/lib/components/chat/ChatPanel.svelte | 48 ++++-- cptr/frontend/src/lib/stores.ts | 51 +++--- cptr/frontend/src/lib/stores/chat.ts | 29 +++- cptr/frontend/src/lib/utils/paths.ts | 17 ++ cptr/frontend/src/routes/+page.svelte | 41 +++-- cptr/models/workspaces.py | 15 ++ cptr/routers/chat.py | 42 +++-- cptr/routers/state.py | 129 ++++++++++++--- cptr/utils/chat_task.py | 72 ++++++-- 13 files changed, 592 insertions(+), 209 deletions(-) create mode 100644 cptr/frontend/src/lib/utils/paths.ts diff --git a/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte b/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte index 209c07a..e9af940 100644 --- a/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte +++ b/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte @@ -8,6 +8,7 @@ import DropdownMenu from '../DropdownMenu.svelte'; import ModelSelector from '../common/ModelSelector.svelte'; import { selectedModelId, workspaceList } from '$lib/stores'; + import { getPathDisplayName } from '$lib/utils/paths'; import { createBot, updateBot, verifyBotToken, type BotData, type BotForm } from '$lib/apis/bots'; import { toast } from 'svelte-sonner'; import { t } from '$lib/i18n'; @@ -35,7 +36,7 @@ // Workspace dropdown let showWsMenu = $state(false); - let wsBtnEl: HTMLButtonElement | undefined = $state(); + let workspaceButtonEl: HTMLButtonElement | undefined = $state(); $effect(() => { if (!workspace && $workspaceList.length > 0) { @@ -43,18 +44,22 @@ } }); - let wsMenuItems = $derived( - $workspaceList.map((ws) => ({ - label: ws.name, + let workspaceMenuItems = $derived( + $workspaceList.map((workspaceOption) => ({ + label: workspaceOption.name, icon: 'folder', - active: ws.path === workspace, + active: workspaceOption.path === workspace, check: true, - onclick: () => { workspace = ws.path; } + onclick: () => { + workspace = workspaceOption.path; + } })) ); - let selectedWsName = $derived( - $workspaceList.find((w) => w.path === workspace)?.name || workspace.split('/').pop() || $t('automationModal.selectWorkspace') + let selectedWorkspaceName = $derived( + $workspaceList.find((w) => w.path === workspace)?.name || + getPathDisplayName(workspace) || + $t('automationModal.selectWorkspace') ); const platformHints: Record = $derived({ @@ -89,7 +94,11 @@ name: name.trim(), model_id: modelId, workspace, - allowed_senders: allowedSenders.split(',').map((s) => s.trim()).filter(Boolean) || undefined + allowed_senders: + allowedSenders + .split(',') + .map((s) => s.trim()) + .filter(Boolean) || undefined }; if (token.trim()) update.token = token.trim(); await updateBot(bot.id, update); @@ -100,7 +109,11 @@ token: token.trim(), model_id: modelId, workspace, - allowed_senders: allowedSenders.split(',').map((s) => s.trim()).filter(Boolean) || undefined + allowed_senders: + allowedSenders + .split(',') + .map((s) => s.trim()) + .filter(Boolean) || undefined }); } onsave(); @@ -141,10 +154,14 @@ />
- + @@ -200,7 +219,9 @@ {#if verifyResult}

{#if verifyResult.ok} - ✓ {verifyResult.info?.username ? `@${verifyResult.info.username}` : `ID: ${verifyResult.info?.id}`} + ✓ {verifyResult.info?.username + ? `@${verifyResult.info.username}` + : `ID: ${verifyResult.info?.id}`} {:else} ✗ {verifyResult.error} {/if} @@ -208,8 +229,12 @@ {/if} - -

{$t('messaging.allowedSendersHint')}

+ +

+ {$t('messaging.allowedSendersHint')} +

@@ -256,10 +289,10 @@
-{#if showWsMenu && wsBtnEl} +{#if showWsMenu && workspaceButtonEl} (showWsMenu = false)} preferAbove={true} maxHeight="15rem" diff --git a/cptr/frontend/src/lib/components/SearchModal.svelte b/cptr/frontend/src/lib/components/SearchModal.svelte index d15c9f1..c7c3080 100644 --- a/cptr/frontend/src/lib/components/SearchModal.svelte +++ b/cptr/frontend/src/lib/components/SearchModal.svelte @@ -1,5 +1,6 @@