diff --git a/coworker/web/__init__.py b/coworker/web/__init__.py index e397eab8..0cededf9 100644 --- a/coworker/web/__init__.py +++ b/coworker/web/__init__.py @@ -7,6 +7,7 @@ DuckDuckGoProvider, SearchResult, TavilyProvider, + SerperProvider, WebSearchProvider, build_provider, provider_names, @@ -20,6 +21,7 @@ "DuckDuckGoProvider", "TavilyProvider", "BraveProvider", + "SerperProvider", "build_provider", "provider_names", "make_web_search_tool", diff --git a/coworker/web/providers.py b/coworker/web/providers.py index 23cad857..369f2756 100644 --- a/coworker/web/providers.py +++ b/coworker/web/providers.py @@ -108,10 +108,41 @@ def search(self, query: str, max_results: int = 5) -> list[SearchResult]: ] +class SerperProvider(WebSearchProvider): + name = "serper" + requires_key = True + + def __init__(self, api_key: str) -> None: + self.api_key = api_key + + def search(self, query: str, max_results: int = 5) -> list[SearchResult]: + import httpx + + resp = httpx.post( + "https://google.serper.dev/search", + headers={ + "X-API-KEY": self.api_key, + "Content-Type": "application/json", + }, + json={"q": query, "num": max_results}, + timeout=_TIMEOUT, + ) + data = resp.json() + return [ + SearchResult( + title=r.get("title", ""), + url=r.get("link", ""), + snippet=r.get("snippet", ""), + ) + for r in data.get("organic", []) + ] + + _PROVIDERS = { "duckduckgo": DuckDuckGoProvider, "tavily": TavilyProvider, "brave": BraveProvider, + "serper": SerperProvider, } diff --git a/surfaces/gui/package-lock.json b/surfaces/gui/package-lock.json index 56baf1e1..0732b119 100644 --- a/surfaces/gui/package-lock.json +++ b/surfaces/gui/package-lock.json @@ -21,6 +21,7 @@ "@tauri-apps/cli": "^2.11.2", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@types/node": "^26.1.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", @@ -1941,6 +1942,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -5618,6 +5629,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/surfaces/gui/package.json b/surfaces/gui/package.json index c909514d..80f81bc5 100644 --- a/surfaces/gui/package.json +++ b/surfaces/gui/package.json @@ -27,6 +27,7 @@ "@tauri-apps/cli": "^2.11.2", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "@types/node": "^26.1.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 852e4d6f..75683fc2 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -30,7 +30,7 @@ const PERMISSION_OPTIONS: Option[] = [ // goes stale and silently offers ids the backend never confirmed (caught 2026-07-21). // Drop the provider prefix for display (anthropic:claude-opus-4-8 → claude-opus-4-8); full id on hover. -const shortModel = (m: string) => (m.includes(":") ? m.split(":").slice(1).join(":") : m); +const shortModel = (m?: string) => (m && m.includes(":") ? m.split(":").slice(1).join(":") : m || ""); // Identify an attachment by name + payload size so duplicates (e.g. the same file picked twice, // or a prefill applied twice) collapse to one chip. @@ -323,10 +323,12 @@ export function Composer(props: Props) { const modelsLoaded = !!(props.models && props.models.length); const modelOptions: Option[] = Array.from( new Set([props.model, ...(props.models || [])]), - ).map((m) => ({ - value: m, - label: props.modelLabels?.[m] || shortModel(m), - })); + ) + .filter(Boolean) + .map((m) => ({ + value: m, + label: props.modelLabels?.[m] || shortModel(m), + })); const iconBtn = "w-7 h-7 grid place-items-center rounded-md text-muted hover:text-ink hover:bg-paper shrink-0"; @@ -516,8 +518,15 @@ export function Composer(props: Props) { {/* send / stop */} {props.running ? ( - ) : (