From 7ea5f289ae4143881eb9a1a85d0c1c89d2cb4546 Mon Sep 17 00:00:00 2001 From: valueadd-lgtm <339269770+valueadd-lgtm@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:47:02 +0000 Subject: [PATCH] feat: add You.com as an optional web-search provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You.com joins native and Tavily as a selectable provider for the web_search, fetch_page and deep_search tools, mirroring the Tavily backend's shape: a search.Backend implementation over the You.com Search and Contents APIs, wired into BuildBackend so the native engine chain stays the last-link fallback. Its key is read from the environment (YDC_API_KEY) rather than stored config, so the settings surface for it is a provider tab plus a pointer to the variable — no new stored credential. Results and pages are stamped provider "youcom", and selecting it without the key set falls back to native with a warning logged, the same way an unkeyed Tavily selection does. Signed-off-by: valueadd-lgtm <339269770+valueadd-lgtm@users.noreply.github.com> --- .../src/content/docs/search-and-skills.md | 1 + internal/search/backend.go | 11 +- internal/search/build.go | 30 ++- internal/search/youcom.go | 194 ++++++++++++++++++ internal/search/youcom_live_test.go | 53 +++++ internal/search/youcom_test.go | 187 +++++++++++++++++ internal/server/server.go | 10 +- internal/session/search_store.go | 13 +- web/src/api/client.ts | 4 +- web/src/pages/settings/search.tsx | 19 ++ 10 files changed, 504 insertions(+), 18 deletions(-) create mode 100644 internal/search/youcom.go create mode 100644 internal/search/youcom_live_test.go create mode 100644 internal/search/youcom_test.go diff --git a/docs-site/src/content/docs/search-and-skills.md b/docs-site/src/content/docs/search-and-skills.md index d2fddfa..b878042 100644 --- a/docs-site/src/content/docs/search-and-skills.md +++ b/docs-site/src/content/docs/search-and-skills.md @@ -21,6 +21,7 @@ Point it at your own search if you have one (for example a self-hosted SearxNG), | --- | --- | | `OGCODE_SEARXNG_URL` | Use a SearxNG instance for search | | `TAVILY_API_KEY` | Use Tavily for search and page reading | +| `YDC_API_KEY` | Use You.com for search and page reading (select the You.com provider in Settings → Web search) | Two knobs tune deeper research — how many pages it reads and how much of each — but the defaults are sensible and most people never touch them. diff --git a/internal/search/backend.go b/internal/search/backend.go index d9745c9..e5e3f61 100644 --- a/internal/search/backend.go +++ b/internal/search/backend.go @@ -32,11 +32,11 @@ type PageContent struct { // Backend is the web-search capability behind the web_search and fetch_page // tools and the deep-research pipeline. Implementations are NativeBackend (the -// HTTP engine chain), TavilyBackend, and the platform browser backends — -// SafariBackend on macOS, ChromiumBackend on Windows — composed into chains -// with SwitchableBackend and NewFallbackBackend; the interface is what the -// tools and the pipeline depend on, and what a test substitutes a fake for to -// stay off the network. +// HTTP engine chain), TavilyBackend, YoucomBackend, and the platform browser +// backends — SafariBackend on macOS, ChromiumBackend on Windows — composed +// into chains with SwitchableBackend and NewFallbackBackend; the interface is +// what the tools and the pipeline depend on, and what a test substitutes a +// fake for to stay off the network. type Backend interface { // Name reports the provider name this backend stamps on its answers. A // fallback chain reports its primary's name: which backend answered a given @@ -55,6 +55,7 @@ var _ Backend = (*NativeBackend)(nil) // leave on results and pages. const ( ProviderTavily = "tavily" + ProviderYoucom = "youcom" ProviderNative = "native" ProviderSafari = "safari" ProviderChromium = "chromium" diff --git a/internal/search/build.go b/internal/search/build.go index bbd51ca..80b4792 100644 --- a/internal/search/build.go +++ b/internal/search/build.go @@ -6,13 +6,13 @@ import ( ) // BuildBackend assembles the search backend chain a deep-research run uses. -// provider is matched against ProviderTavily; anything else — the empty string -// included — selects the native chain. It is matched here rather than against -// the session package's copy of the id so this stays a leaf package any entry -// point can build a backend from. +// provider is matched against ProviderTavily and ProviderYoucom; anything +// else — the empty string included — selects the native chain. It is matched +// here rather than against the session package's copy of the id so this stays +// a leaf package any entry point can build a backend from. // // The native chain is the owned default and always the last link, so an -// answerable query is never lost to a third-party outage: a bad Tavily key, an +// answerable query is never lost to a third-party outage: a bad key, an // exhausted quota or a network error falls through to it rather than failing // the call. browser picks which native engine leads — "native" for the HTTP // path alone, "safari" or "chrome" to drive a real browser first (Safari on @@ -25,6 +25,11 @@ import ( // touching stored config. An empty key means no Tavily link at all — the // provider selection alone is not enough to build one. // +// The You.com provider takes its key from the environment alone (YDC_API_KEY) +// — the settings UI stores no credential for it — so selecting provider +// "youcom" without that variable set resolves to the native chain, the same +// way an unkeyed Tavily selection does. +// // It takes plain strings rather than a config struct because both the server // and the headless CLI build a backend from their own config sources, and the // prompt they hand the agent names deep_search either way: whichever entry @@ -51,11 +56,17 @@ func BuildBackend(provider, tavilyAPIKey, browser string) Backend { nativeChain = NewFallbackBackend(native, NewBrowserBackend()) } - if strings.EqualFold(strings.TrimSpace(provider), ProviderTavily) { + trimmed := strings.ToLower(strings.TrimSpace(provider)) + if trimmed == ProviderTavily { if key := resolveTavilyKey(tavilyAPIKey); key != "" { return NewFallbackBackend(NewTavilyBackend(key), nativeChain) } } + if trimmed == ProviderYoucom { + if key := resolveYoucomKey(); key != "" { + return NewFallbackBackend(NewYoucomBackend(key), nativeChain) + } + } return nativeChain } @@ -67,3 +78,10 @@ func resolveTavilyKey(stored string) string { } return strings.TrimSpace(stored) } + +// resolveYoucomKey returns the You.com key in effect. It comes from the +// environment alone — the settings UI stores no credential for the You.com +// provider — so the key travels with the deployment, not the database. +func resolveYoucomKey() string { + return strings.TrimSpace(os.Getenv("YDC_API_KEY")) +} diff --git a/internal/search/youcom.go b/internal/search/youcom.go new file mode 100644 index 0000000..79609e9 --- /dev/null +++ b/internal/search/youcom.go @@ -0,0 +1,194 @@ +package search + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// YoucomBackend answers web_search and fetch_page through the You.com Search +// and Contents APIs (https://you.com) using the user's own API key. It +// implements the same Backend interface as NativeBackend, so once constructed +// it is indistinguishable to the tools and the deep-research pipeline. +// +// You.com authenticates with a static key sent in the X-API-Key header; there +// is no OAuth or token refresh to manage. When a call fails — a bad key, an +// exhausted quota, a network blip — the caller wraps this backend with +// NewFallbackBackend(youcom, native) so search transparently falls back to the +// built-in engines rather than going dark. +// +// The key is supplied through the environment (YDC_API_KEY) rather than stored +// config, so it travels with the deployment instead of the database. +type YoucomBackend struct { + apiKey string + client *http.Client + // baseURL is the API root, overridable in tests. Defaults to youcomBaseURL. + baseURL string +} + +var _ Backend = (*YoucomBackend)(nil) + +const ( + youcomBaseURL = "https://ydc-index.io" + + // Per-operation deadlines, mirroring the Tavily backend: extraction is + // heavier than a search, so it gets the longer budget. + youcomSearchTimeout = 15 * time.Second + youcomFetchTimeout = 30 * time.Second + + // Cap the requested result count. You.com defaults to 10; anything larger + // is clamped rather than sent as-is. + youcomMaxResults = 20 + + // Match the native backend's per-page truncation so the fetch_page tool's + // "[content truncated at 14,000 characters]" note stays accurate + // regardless of which backend served the page. + youcomPageChars = nativePageChars + + // Cap the body we read from an error response so a misbehaving endpoint + // cannot stream an unbounded error into a log line. + youcomErrBodyCap = 2 << 10 +) + +// NewYoucomBackend returns a backend that talks to the You.com API with +// apiKey. The key is not validated here; a bad key surfaces as an error on the +// first call. +func NewYoucomBackend(apiKey string) *YoucomBackend { + return &YoucomBackend{ + apiKey: strings.TrimSpace(apiKey), + client: &http.Client{Timeout: youcomFetchTimeout + 5*time.Second}, + baseURL: youcomBaseURL, + } +} + +// Name reports the provider name stamped on this backend's results. +func (y *YoucomBackend) Name() string { return ProviderYoucom } + +// Search returns up to limit results for query via POST /v1/search. +func (y *YoucomBackend) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) { + if limit <= 0 { + limit = 8 + } + if limit > youcomMaxResults { + limit = youcomMaxResults + } + + reqBody := map[string]any{ + "query": query, + "count": limit, + } + var resp struct { + Results struct { + Web []struct { + Title string `json:"title"` + URL string `json:"url"` + Description string `json:"description"` + Snippets []string `json:"snippets"` + } `json:"web"` + } `json:"results"` + } + if err := y.post(ctx, "/v1/search", reqBody, &resp, youcomSearchTimeout); err != nil { + return nil, err + } + + out := make([]SearchResult, 0, len(resp.Results.Web)) + for _, r := range resp.Results.Web { + if r.URL == "" { + continue + } + // description is the one-line summary; the snippets array is the longer + // form. Use whichever the response carried. + snippet := r.Description + if snippet == "" && len(r.Snippets) > 0 { + snippet = strings.Join(r.Snippets, " … ") + } + out = append(out, SearchResult{Title: r.Title, URL: r.URL, Snippet: snippet, Provider: ProviderYoucom}) + } + return out, nil +} + +// FetchPage returns the readable content of url via POST /v1/contents. +func (y *YoucomBackend) FetchPage(ctx context.Context, rawURL string) (PageContent, error) { + reqBody := map[string]any{ + "urls": []string{rawURL}, + "formats": []string{"markdown"}, + } + var resp struct { + URL string `json:"url"` + Title string `json:"title"` + Markdown *string `json:"markdown"` + } + if err := y.post(ctx, "/v1/contents", reqBody, &resp, youcomFetchTimeout); err != nil { + return PageContent{}, err + } + + if resp.Markdown == nil || strings.TrimSpace(*resp.Markdown) == "" { + return PageContent{}, fmt.Errorf("fetch %s: youcom returned no readable content", rawURL) + } + + title := resp.Title + if title == "" { + title = titleFromURL(rawURL) + } + + // You.com's markdown already carries its own structure, so like the Tavily + // path this is not run through normalizeWhitespace. Only trim and truncate. + text, truncated := truncateChars(strings.TrimSpace(*resp.Markdown), youcomPageChars) + return PageContent{URL: rawURL, Title: title, Text: text, Truncated: truncated, Provider: ProviderYoucom}, nil +} + +// post sends body as JSON to the You.com endpoint at path and decodes a 200 +// response into out. A non-2xx status becomes an error carrying a short +// snippet of the response body, with 401 called out plainly since a bad key is +// the most common misconfiguration. +func (y *YoucomBackend) post(ctx context.Context, path string, body, out any, timeout time.Duration) error { + if y.apiKey == "" { + return fmt.Errorf("youcom: no API key configured") + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + payload, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("youcom: encode request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, y.baseURL+path, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("youcom: build request: %w", err) + } + req.Header.Set("X-API-Key", y.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := y.client.Do(req) + if err != nil { + return fmt.Errorf("youcom %s: %w", path, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, youcomErrBodyCap)) + msg := strings.TrimSpace(string(snippet)) + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("youcom %s: the API key was rejected (status %d)", path, resp.StatusCode) + case http.StatusTooManyRequests: + return fmt.Errorf("youcom %s: rate limited or out of credits (status %d)", path, resp.StatusCode) + default: + if msg != "" { + return fmt.Errorf("youcom %s: status %d: %s", path, resp.StatusCode, msg) + } + return fmt.Errorf("youcom %s: status %d", path, resp.StatusCode) + } + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("youcom %s: decode response: %w", path, err) + } + return nil +} diff --git a/internal/search/youcom_live_test.go b/internal/search/youcom_live_test.go new file mode 100644 index 0000000..56c0619 --- /dev/null +++ b/internal/search/youcom_live_test.go @@ -0,0 +1,53 @@ +package search + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +// Live You.com checks, opt-in for the same reasons as the native live tests: +// they cost credits and fail on a flaky network. +// +// YDC_API_KEY= OGCODE_LIVE_SEARCH_TEST=1 go test ./internal/search/ -run TestLiveYoucom -v +func TestLiveYoucom(t *testing.T) { + requireLive(t) + key := os.Getenv("YDC_API_KEY") + if key == "" { + t.Skip("set YDC_API_KEY to run live You.com tests") + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + b := NewYoucomBackend(key) + + results, err := b.Search(ctx, "golang context cancellation", 5) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(results) == 0 { + t.Fatal("got 0 results, want at least 1") + } + for i, r := range results { + if !strings.HasPrefix(r.URL, "http") || r.Title == "" { + t.Errorf("result %d is malformed: %+v", i, r) + } + if r.Provider != ProviderYoucom { + t.Errorf("result %d provider = %q, want %q", i, r.Provider, ProviderYoucom) + } + } + + page, err := b.FetchPage(ctx, results[0].URL) + if err != nil { + t.Fatalf("fetch %s: %v", results[0].URL, err) + } + if strings.TrimSpace(page.Text) == "" { + t.Errorf("page %s extracted no text", results[0].URL) + } + if page.Provider != ProviderYoucom { + t.Errorf("page provider = %q, want %q", page.Provider, ProviderYoucom) + } +} diff --git a/internal/search/youcom_test.go b/internal/search/youcom_test.go new file mode 100644 index 0000000..4917b9d --- /dev/null +++ b/internal/search/youcom_test.go @@ -0,0 +1,187 @@ +package search + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// newTestYoucom points a backend at a stub server instead of the real API. +func newTestYoucom(url string) *YoucomBackend { + b := NewYoucomBackend("ydc-test-key") + b.baseURL = url + return b +} + +func TestYoucomSearchParsesResults(t *testing.T) { + var gotKey, gotPath string + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-API-Key") + gotPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + _, _ = io.WriteString(w, `{"results":{"web":[ + {"title":"First","url":"https://a.example","description":"snippet a"}, + {"title":"Second","url":"https://b.example","snippets":["snippet b1","snippet b2"]}, + {"title":"NoURL","url":"","description":"dropped"} + ]}}`) + })) + defer srv.Close() + + results, err := newTestYoucom(srv.URL).Search(context.Background(), "hello", 5) + if err != nil { + t.Fatalf("Search: %v", err) + } + if gotKey != "ydc-test-key" { + t.Errorf("X-API-Key header = %q, want ydc-test-key", gotKey) + } + if gotPath != "/v1/search" { + t.Errorf("path = %q, want /v1/search", gotPath) + } + if gotBody["count"] != float64(5) { + t.Errorf("count = %v, want 5", gotBody["count"]) + } + // The result with an empty URL is dropped. + if len(results) != 2 { + t.Fatalf("got %d results, want 2", len(results)) + } + if results[0].Title != "First" || results[0].URL != "https://a.example" || results[0].Snippet != "snippet a" { + t.Errorf("first result mapped wrong: %+v", results[0]) + } + // A result without a description falls back to joining its snippets. + if results[1].Snippet != "snippet b1 … snippet b2" { + t.Errorf("snippet fallback = %q, want joined snippets", results[1].Snippet) + } +} + +func TestYoucomSearchClampsLimit(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + _, _ = io.WriteString(w, `{"results":{"web":[]}}`) + })) + defer srv.Close() + + if _, err := newTestYoucom(srv.URL).Search(context.Background(), "q", 999); err != nil { + t.Fatalf("Search: %v", err) + } + if gotBody["count"] != float64(youcomMaxResults) { + t.Errorf("count = %v, want clamp to %d", gotBody["count"], youcomMaxResults) + } +} + +func TestYoucomFetchPageParses(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/contents" { + t.Errorf("path = %q, want /v1/contents", r.URL.Path) + } + _, _ = io.WriteString(w, `{"url":"https://a.example/post","title":"A Post","markdown":"# Title\n\nBody text."}`) + })) + defer srv.Close() + + page, err := newTestYoucom(srv.URL).FetchPage(context.Background(), "https://www.a.example/post") + if err != nil { + t.Fatalf("FetchPage: %v", err) + } + if !strings.Contains(page.Text, "Body text.") { + t.Errorf("text missing body: %q", page.Text) + } + // Markdown structure (the blank line) is preserved, not flattened. + if !strings.Contains(page.Text, "\n") { + t.Errorf("expected newlines preserved, got %q", page.Text) + } + if page.Title != "A Post" { + t.Errorf("title = %q, want A Post", page.Title) + } +} + +func TestYoucomFetchPageDerivesTitle(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"markdown":"Body text."}`) + })) + defer srv.Close() + + page, err := newTestYoucom(srv.URL).FetchPage(context.Background(), "https://www.a.example/post") + if err != nil { + t.Fatalf("FetchPage: %v", err) + } + // No title in the response: one is derived from the URL, as the Tavily + // backend does. + if page.Title != "a.example/post" { + t.Errorf("title = %q, want a.example/post", page.Title) + } +} + +func TestYoucomFetchPageReportsFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"url":"https://x","markdown":null}`) + })) + defer srv.Close() + + if _, err := newTestYoucom(srv.URL).FetchPage(context.Background(), "https://x"); err == nil { + t.Fatal("want an error when the API returns no markdown, got nil") + } +} + +func TestYoucomRejectedKey(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"detail":"unauthorized"}`) + })) + defer srv.Close() + + _, err := newTestYoucom(srv.URL).Search(context.Background(), "q", 3) + if err == nil || !strings.Contains(err.Error(), "rejected") { + t.Fatalf("want a 'rejected' error on 401, got %v", err) + } +} + +func TestYoucomMissingKey(t *testing.T) { + b := NewYoucomBackend(" ") + if _, err := b.Search(context.Background(), "q", 3); err == nil || !strings.Contains(err.Error(), "no API key") { + t.Fatalf("want a 'no API key' error for a blank key, got %v", err) + } +} + +// The provider stamp is how web_search and fetch_page show which backend +// answered; You.com must leave it on both kinds of answer. +func TestYoucomStampsProvider(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"results":{"web":[{"title":"First","url":"https://a.example","description":"snippet a"}]}}`) + })) + defer srv.Close() + b := newTestYoucom(srv.URL) + + results, err := b.Search(context.Background(), "q", 5) + if err != nil { + t.Fatalf("Search: %v", err) + } + for i, r := range results { + if r.Provider != ProviderYoucom { + t.Errorf("result %d provider = %q, want %q", i, r.Provider, ProviderYoucom) + } + } + + extract := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"url":"https://a.example/post","markdown":"Body text."}`) + })) + defer extract.Close() + b = newTestYoucom(extract.URL) + + page, err := b.FetchPage(context.Background(), "https://www.a.example/post") + if err != nil { + t.Fatalf("FetchPage: %v", err) + } + if page.Provider != ProviderYoucom { + t.Errorf("page provider = %q, want %q", page.Provider, ProviderYoucom) + } + if b.Name() != ProviderYoucom { + t.Errorf("Name() = %q, want %q", b.Name(), ProviderYoucom) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index ba1a0b4..7c60407 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1022,7 +1022,7 @@ func tavilyKeyFor(cfg *session.SearchConfig) string { } // logSearchProvider records which backend cfg resolves to, with a warning when -// Tavily is selected but unusable (so it silently runs on native). +// Tavily or You.com is selected but unusable (so it silently runs on native). func logSearchProvider(prefix string, cfg *session.SearchConfig) { if cfg.Provider == session.SearchProviderTavily { if tavilyKeyFor(cfg) != "" { @@ -1032,5 +1032,13 @@ func logSearchProvider(prefix string, cfg *session.SearchConfig) { slog.Warn(prefix + "; provider=tavily but no API key is configured — using the native engine") return } + if cfg.Provider == session.SearchProviderYoucom { + if strings.TrimSpace(os.Getenv("YDC_API_KEY")) != "" { + slog.Info(prefix + "; provider=youcom (native fallback)") + return + } + slog.Warn(prefix + "; provider=youcom but no YDC_API_KEY is set in the environment — using the native engine") + return + } slog.Info(prefix + "; provider=native") } diff --git a/internal/session/search_store.go b/internal/session/search_store.go index 1c4e2b6..ed29e7c 100644 --- a/internal/session/search_store.go +++ b/internal/session/search_store.go @@ -8,11 +8,15 @@ import ( ) // Search providers. Native is the built-in engine compiled into the binary; -// Tavily is a third-party API keyed by the user's own token. The value is -// stored as a string so future providers slot in without a schema change. +// Tavily is a third-party API keyed by the user's own token. You.com is a +// third-party API too, but its key is read from the environment (YDC_API_KEY) +// rather than stored, so the provider column is all the session keeps for it. +// The value is stored as a string so future providers slot in without a schema +// change. const ( SearchProviderNative = "native" SearchProviderTavily = "tavily" + SearchProviderYoucom = "youcom" ) // Which native engine leads the chain. SearchBrowserNative uses the HTTP path @@ -46,7 +50,8 @@ const MaskedAPIKey = "__SET__" // and its credential. type SearchConfig struct { Enabled bool `json:"enabled"` - // Provider selects the search backend: "native" (default) or "tavily". + // Provider selects the search backend: "native" (default), "tavily", or + // "youcom" (keyed by the YDC_API_KEY environment variable). Provider string `json:"provider"` // TavilyAPIKey is the token for the Tavily provider. Masked to MaskedAPIKey // on read so it never reaches the UI in the clear. @@ -67,7 +72,7 @@ type SearchConfig struct { // write so consumers always see a valid config regardless of how the row was // populated or what a client sent. func (c *SearchConfig) normalise() { - if c.Provider != SearchProviderTavily { + if c.Provider != SearchProviderTavily && c.Provider != SearchProviderYoucom { c.Provider = SearchProviderNative } c.FetchTopK = clampInt(c.FetchTopK, MinSearchFetchTopK, MaxSearchFetchTopK, DefaultSearchFetchTopK) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c9234e5..39380b2 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1168,7 +1168,7 @@ export function checkForUpdate(): Promise { } // Search Config API -export type SearchProvider = 'native' | 'tavily'; +export type SearchProvider = 'native' | 'tavily' | 'youcom'; // Which engine leads the native backend: '' = HTTP (the fast default) first // with a browser as fallback, 'native' = HTTP only, 'safari' = Safari first on @@ -1178,7 +1178,7 @@ export type SearchBrowser = '' | 'native' | 'safari' | 'chrome'; export interface SearchConfig { enabled: boolean; // Which search backend answers web_search/fetch_page: the built-in native - // engine, or a third-party provider (Tavily). + // engine, or a third-party provider (Tavily, You.com). provider: SearchProvider; // Tavily API key. On read it is the sentinel '__SET__' when a key is stored // (never the real value) or '' when none is. On write, echo '__SET__' back to diff --git a/web/src/pages/settings/search.tsx b/web/src/pages/settings/search.tsx index 27c5a9d..321d2de 100644 --- a/web/src/pages/settings/search.tsx +++ b/web/src/pages/settings/search.tsx @@ -32,6 +32,7 @@ const ICONS = { const TABS: Array<{ id: SearchProvider; label: string }> = [ { id: 'native', label: 'Native' }, { id: 'tavily', label: 'Tavily' }, + { id: 'youcom', label: 'You.com' }, ]; // How the native engine leads. '' keeps the HTTP path in front with the @@ -490,6 +491,24 @@ export default function SearchSettings() { + + + + You.com uses a static key from the server environment — set YDC_API_KEY{' '} + and it is picked up live, no restart needed. The native engines stay behind it as + fallback, so a missing or rejected key never leaves a query unanswered. + + } + hidden={hide('You.com API key', 'ydc credentials key search provider')} + > + + Get a key at you.com + + +