diff --git a/.env b/.env
index 6dbec16f5db..0c803a012a5 100644
--- a/.env
+++ b/.env
@@ -171,3 +171,27 @@ OPENID_PROVIDER_URL=https://huggingface.co # for Google, use https://accounts.go
OPENID_TOLERANCE=
OPENID_RESOURCE=
EXPOSE_API=# deprecated, API is now always exposed
+
+### Web Search API Keys ###
+# Google Custom Search (Recommended)
+GOOGLE_SEARCH_API_KEY=#your Google Custom Search API key
+GOOGLE_SEARCH_ENGINE_ID=#your Google Search Engine ID
+
+# Exa MCP (AI-Powered Neural Search via Smithery) - NEW!
+EXA_API_KEY=#your Exa API key from https://exa.ai or https://smithery.ai/server/exa
+# Optional: Custom MCP endpoint (defaults to https://mcp.exa.ai/mcp)
+# EXA_MCP_ENDPOINT=https://mcp.exa.ai/mcp
+
+# Bing Search API
+BING_SEARCH_API_KEY=your_bing_key_here
+
+# SerpAPI (Good for development)
+SERPAPI_API_KEY=your_serpapi_key_here
+
+# Brave Search API
+BRAVE_SEARCH_API_KEY=#your Brave Search API key
+
+# You.com Search API
+YOUCOM_API_KEY=#your You.com API key
+
+# DuckDuckGo - Free, no API key required (automatically enabled)
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index abc7a800c6b..2935bc13595 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,14 +3,13 @@ node_modules
/build
/.svelte-kit
/package
-.env
-.env.*
+.env.example
+.env.local
+!.env.ci
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
SECRET_CONFIG
.idea
-!.env.ci
-!.env
gcp-*.json
db
models/*
diff --git a/README.md b/README.md
index d2c95061427..9ff0f9527c2 100644
--- a/README.md
+++ b/README.md
@@ -7,9 +7,10 @@ A chat interface for LLMs. It is a SvelteKit app and it powers the [HuggingChat
0. [Quickstart](#quickstart)
1. [Database Options](#database-options)
2. [Launch](#launch)
-3. [Optional Docker Image](#optional-docker-image)
-4. [Extra parameters](#extra-parameters)
-5. [Building](#building)
+3. [Web Search](#web-search)
+4. [Optional Docker Image](#optional-docker-image)
+5. [Extra parameters](#extra-parameters)
+6. [Building](#building)
> [!NOTE]
> Chat UI only supports OpenAI-compatible APIs via `OPENAI_BASE_URL` and the `/models` endpoint. Provider-specific integrations (legacy `MODELS` env var, GGUF discovery, embeddings, web-search helpers, etc.) are removed, but any service that speaks the OpenAI protocol (llama.cpp server, Ollama, OpenRouter, etc. will work by default).
@@ -89,6 +90,76 @@ npm run dev
The dev server listens on `http://localhost:5173` by default. Use `npm run build` / `npm run preview` for production builds.
+## Web Search
+
+Chat UI includes a powerful web search feature with support for **7 search providers** including **Exa MCP (Model Context Protocol)** integration via Smithery.
+
+### Quick Setup
+
+Add at least one search provider API key to your `.env.local`:
+
+```env
+# Exa MCP (AI-Powered Neural Search - Recommended!)
+EXA_API_KEY=your_exa_api_key
+
+# Google Custom Search (Also Recommended)
+GOOGLE_SEARCH_API_KEY=your_google_api_key
+GOOGLE_SEARCH_ENGINE_ID=your_search_engine_id
+
+# Other providers (optional)
+BING_SEARCH_API_KEY=your_bing_key
+SERPAPI_API_KEY=your_serpapi_key
+BRAVE_SEARCH_API_KEY=your_brave_key
+YOUCOM_API_KEY=your_youcom_key
+# DuckDuckGo is free and enabled by default
+```
+
+### Supported Providers
+
+1. **Google Custom Search** - Most reliable, good results
+2. **Exa MCP (Smithery)** - ๐ AI-powered neural search with MCP support
+3. **Bing Search API** - Microsoft's search
+4. **SerpAPI** - Easy setup, good for development
+5. **DuckDuckGo** - Free, no API key required
+6. **Brave Search** - Privacy-focused
+7. **You.com** - AI-powered search
+
+### Features
+
+- โ
**Intelligent Fallback**: Automatically tries providers in priority order
+- โ
**Rate Limiting**: Per-minute and daily limits for each provider
+- โ
**Analytics**: Real-time monitoring and health reports
+- โ
**MCP Integration**: Full Model Context Protocol support via Exa
+- โ
**Customizable Patterns**: 12+ detection patterns
+- โ
**Mock Data**: Works even without API keys for testing
+
+### Usage
+
+Simply include web search triggers in your messages:
+
+```
+๐ using web search what is quantum computing?
+web search latest AI news
+search the web for blockchain information
+```
+
+### Documentation
+
+- ๐ **[WEB_SEARCH_SETUP.md](./WEB_SEARCH_SETUP.md)** - Complete setup guide for all providers
+- ๐ **[WEB_SEARCH_COMPLETE.md](./WEB_SEARCH_COMPLETE.md)** - Implementation details and features
+- ๐ **[WEB_SEARCH_MCP_EXA.md](./WEB_SEARCH_MCP_EXA.md)** - Exa MCP integration guide
+
+### Exa MCP (New!)
+
+Exa MCP provides AI-powered neural search with:
+
+- ๐ง **Neural Search**: AI understanding of query intent and context
+- ๐ฏ **High-Quality Results**: Fresh, relevant content from trusted sources
+- ๐ป **Code Context**: Specialized search for programming queries
+- ๐ **MCP Support**: Full Model Context Protocol integration
+
+Get your Exa API key from [exa.ai](https://exa.ai) or [Smithery](https://smithery.ai/server/exa).
+
## Optional Docker Image
Prefer containerized setup? You can run everything in one container as long as you supply a MongoDB URI (local or hosted):
diff --git a/package-lock.json b/package-lock.json
index e38bb0445ac..bac28d18519 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -60,7 +60,7 @@
"@types/katex": "^0.16.7",
"@types/mime-types": "^2.1.4",
"@types/minimist": "^1.2.5",
- "@types/node": "^22.1.0",
+ "@types/node": "^22.18.11",
"@types/parquetjs": "^0.10.3",
"@types/uuid": "^9.0.8",
"@types/yazl": "^3.3.0",
@@ -271,7 +271,6 @@
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
@@ -286,7 +285,6 @@
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -388,6 +386,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -411,6 +410,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -2441,13 +2441,6 @@
"integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==",
"license": "MIT"
},
- "node_modules/@sinclair/typebox": {
- "version": "0.34.33",
- "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.33.tgz",
- "integrity": "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g==",
- "license": "MIT",
- "optional": true
- },
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
@@ -2479,6 +2472,7 @@
"integrity": "sha512-EMYTY4+rNa7TaRZYzCqhQslEkACEZzWc363jOYuc90oJrgvlWTcgqTxcGSIJim48hPaXwYlHyatRnnMmTFf5tA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/cookie": "^0.6.0",
@@ -2511,6 +2505,7 @@
"integrity": "sha512-wojIS/7GYnJDYIg1higWj2ROA6sSRWvcR1PO/bqEyFr/5UZah26c8Cz4u0NaqjPeVltzsVpt2Tm8d2io0V+4Tw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
@@ -2586,7 +2581,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12",
"npm": ">=6"
@@ -2632,8 +2626,7 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.2",
@@ -2721,9 +2714,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.15.30",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.30.tgz",
- "integrity": "sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==",
+ "version": "22.18.11",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz",
+ "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -2862,6 +2855,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -3226,6 +3220,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3370,7 +3365,6 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
@@ -3580,6 +3574,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
@@ -4110,7 +4105,6 @@
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=6"
}
@@ -4173,8 +4167,7 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/domexception": {
"version": "4.0.0",
@@ -4242,6 +4235,7 @@
"resolved": "https://registry.npmjs.org/elysia/-/elysia-1.3.4.tgz",
"integrity": "sha512-kAfM3Zwovy3z255IZgTKVxBw91HbgKhYl3TqrGRdZqqr+Fd+4eKOfvxgaKij22+MZLczPzIHtscAmvfpI3+q/A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cookie": "^1.0.2",
"exact-mirror": "0.1.2",
@@ -4433,6 +4427,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4920,6 +4915,7 @@
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.0.0.tgz",
"integrity": "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@tokenizer/inflate": "^0.2.7",
"strtok3": "^10.2.2",
@@ -5961,8 +5957,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
@@ -6417,7 +6412,6 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"license": "MIT",
- "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -7653,6 +7647,7 @@
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"playwright-core": "1.55.1"
},
@@ -7698,6 +7693,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -7929,6 +7925,7 @@
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -7945,6 +7942,7 @@
"integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"prettier": "^3.0.0",
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
@@ -8034,7 +8032,6 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -8049,7 +8046,6 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -8190,8 +8186,7 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/read-cache": {
"version": "1.0.0",
@@ -8346,6 +8341,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz",
"integrity": "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.7"
},
@@ -9058,6 +9054,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.33.14.tgz",
"integrity": "sha512-kRlbhIlMTijbFmVDQFDeKXPLlX1/ovXwV0I162wRqQhRcygaqDIcu1d/Ese3H2uI+yt3uT8E7ndgDthQv5v5BA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -9171,6 +9168,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -9585,6 +9583,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9793,6 +9792,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -9928,6 +9928,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.2.tgz",
"integrity": "sha512-fyNn/Rp016Bt5qvY0OQvIUCwW2vnaEBLxP42PmKbNIoasSYjML+8xyeADOPvBe+Xfl/ubIw4og7Lt9jflRsCNw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.2",
@@ -10250,6 +10251,7 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10.0.0"
},
diff --git a/package.json b/package.json
index 3e7e01069d6..e365d244f17 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,7 @@
"@types/katex": "^0.16.7",
"@types/mime-types": "^2.1.4",
"@types/minimist": "^1.2.5",
- "@types/node": "^22.1.0",
+ "@types/node": "^22.18.11",
"@types/parquetjs": "^0.10.3",
"@types/uuid": "^9.0.8",
"@types/yazl": "^3.3.0",
diff --git a/src/lib/components/chat/ChatMessage.svelte b/src/lib/components/chat/ChatMessage.svelte
index e2f12fc0a1d..0cd564adbd1 100644
--- a/src/lib/components/chat/ChatMessage.svelte
+++ b/src/lib/components/chat/ChatMessage.svelte
@@ -150,7 +150,7 @@
-
+
{/if}
{/each}
@@ -158,7 +158,7 @@
-
+
{/if}
diff --git a/src/lib/server/envCheck.ts b/src/lib/server/envCheck.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/lib/server/textGeneration/index.ts b/src/lib/server/textGeneration/index.ts
index c7b7c70a1c7..8527d060da8 100644
--- a/src/lib/server/textGeneration/index.ts
+++ b/src/lib/server/textGeneration/index.ts
@@ -9,6 +9,7 @@ import {
import { generate } from "./generate";
import { mergeAsyncGenerators } from "$lib/utils/mergeAsyncGenerators";
import type { TextGenerationContext } from "./types";
+import { handleWebSearch, enhanceMessageWithWebSearch } from "./webSearchIntegration";
async function* keepAlive(done: AbortSignal): AsyncGenerator {
while (!done.aborted) {
@@ -46,7 +47,44 @@ async function* textGenerationWithoutTitle(
const preprompt = conv.preprompt;
+ // Handle web search if needed
+ let webSearchSources: { title?: string; link: string }[] = [];
+ const lastMessage = messages[messages.length - 1];
+
+ if (lastMessage && lastMessage.from === 'user') {
+ // Create a mock update function for web search
+ const webSearchUpdate = async (event: MessageUpdate) => {
+ // This will be handled by the main update function in the conversation endpoint
+ // For now, we'll just collect the sources
+ if (event.type === MessageUpdateType.WebSearchSources) {
+ webSearchSources = event.sources;
+ }
+ };
+
+ // Process web search
+ const webSearchResult = await handleWebSearch(
+ await preprocessMessages(messages, convId),
+ webSearchUpdate
+ ).next();
+
+ if (webSearchResult.value?.sources) {
+ webSearchSources = webSearchResult.value.sources;
+ }
+ }
+
const processedMessages = await preprocessMessages(messages, convId);
+
+ // Enhance the last message with web search context if we have results
+ if (webSearchSources.length > 0 && processedMessages.length > 0) {
+ const lastProcessedMessage = processedMessages[processedMessages.length - 1];
+ if (lastProcessedMessage.role === 'user') {
+ lastProcessedMessage.content = enhanceMessageWithWebSearch(
+ lastProcessedMessage.content,
+ webSearchSources
+ );
+ }
+ }
+
yield* generate({ ...ctx, messages: processedMessages }, preprompt);
done.abort();
}
diff --git a/src/lib/server/textGeneration/webSearchIntegration.ts b/src/lib/server/textGeneration/webSearchIntegration.ts
new file mode 100644
index 00000000000..3f64f282654
--- /dev/null
+++ b/src/lib/server/textGeneration/webSearchIntegration.ts
@@ -0,0 +1,91 @@
+import { detectWebSearchRequest, performWebSearch } from "$lib/server/webSearch/webSearchService";
+import { MessageUpdateType, type MessageUpdate } from "$lib/types/MessageUpdate";
+import type { EndpointMessage } from "$lib/server/endpoints/endpoints";
+
+/**
+ * Integrates web search functionality into the text generation pipeline
+ */
+export async function* handleWebSearch(
+ messages: EndpointMessage[],
+ update: (event: MessageUpdate) => Promise
+): AsyncGenerator<{ sources: { title?: string; link: string }[] } | null, void, unknown> {
+ // Check if the last message contains a web search request
+ const lastMessage = messages[messages.length - 1];
+ if (!lastMessage || lastMessage.role !== 'user') {
+ yield null;
+ return;
+ }
+
+ const searchQuery = detectWebSearchRequest(lastMessage.content);
+ if (!searchQuery) {
+ yield null;
+ return;
+ }
+
+ try {
+ // Send web search status update
+ await update({
+ type: MessageUpdateType.WebSearch,
+ status: "searching",
+ query: searchQuery,
+ message: "Searching the web..."
+ });
+
+ // Perform the web search
+ const searchResponse = await performWebSearch(searchQuery);
+
+ // Convert search results to sources format
+ const sources = searchResponse.results.map(result => ({
+ title: result.title,
+ link: result.link
+ }));
+
+ // Send sources update
+ await update({
+ type: MessageUpdateType.WebSearchSources,
+ sources
+ });
+
+ // Send completion status
+ await update({
+ type: MessageUpdateType.WebSearch,
+ status: "completed",
+ query: searchQuery,
+ message: `Found ${sources.length} search results`
+ });
+
+ yield { sources };
+ } catch (error) {
+ console.error("Web search error:", error);
+
+ // Send error status
+ await update({
+ type: MessageUpdateType.WebSearch,
+ status: "error",
+ query: searchQuery,
+ message: "Web search failed"
+ });
+
+ yield null;
+ }
+}
+
+/**
+ * Enhances the user's message with web search context
+ */
+export function enhanceMessageWithWebSearch(
+ originalMessage: string,
+ searchResults: { title?: string; link: string }[]
+): string {
+ if (searchResults.length === 0) {
+ return originalMessage;
+ }
+
+ // Add web search context to the message
+ const searchContext = `\n\nWeb search results:\n${searchResults
+ .map((result, index) => `[${index + 1}] ${result.title}: ${result.link}`)
+ .join('\n')}`;
+
+ return originalMessage + searchContext;
+}
+
diff --git a/src/lib/server/webSearch/analytics.ts b/src/lib/server/webSearch/analytics.ts
new file mode 100644
index 00000000000..9e0b398fc18
--- /dev/null
+++ b/src/lib/server/webSearch/analytics.ts
@@ -0,0 +1,220 @@
+export interface SearchAnalytics {
+ totalSearches: number;
+ successfulSearches: number;
+ failedSearches: number;
+ providerUsage: Record;
+ queryTypes: Record;
+ averageResponseTime: number;
+ lastSearch: Date | null;
+ rateLimitHits: Record;
+}
+
+export interface SearchEvent {
+ timestamp: Date;
+ query: string;
+ provider: string;
+ success: boolean;
+ responseTime: number;
+ resultCount: number;
+ error?: string;
+}
+
+// In-memory analytics storage (in production, use a database)
+const analytics: SearchAnalytics = {
+ totalSearches: 0,
+ successfulSearches: 0,
+ failedSearches: 0,
+ providerUsage: {},
+ queryTypes: {},
+ averageResponseTime: 0,
+ lastSearch: null,
+ rateLimitHits: {}
+};
+
+const searchEvents: SearchEvent[] = [];
+
+/**
+ * Record a search event
+ */
+export function recordSearchEvent(event: Omit): void {
+ const searchEvent: SearchEvent = {
+ ...event,
+ timestamp: new Date()
+ };
+
+ searchEvents.push(searchEvent);
+
+ // Update analytics
+ analytics.totalSearches++;
+ if (event.success) {
+ analytics.successfulSearches++;
+ } else {
+ analytics.failedSearches++;
+ }
+
+ // Update provider usage
+ analytics.providerUsage[event.provider] = (analytics.providerUsage[event.provider] || 0) + 1;
+
+ // Update query types (simple categorization)
+ const queryType = categorizeQuery(event.query);
+ analytics.queryTypes[queryType] = (analytics.queryTypes[queryType] || 0) + 1;
+
+ // Update average response time
+ const totalTime = analytics.averageResponseTime * (analytics.totalSearches - 1) + event.responseTime;
+ analytics.averageResponseTime = totalTime / analytics.totalSearches;
+
+ analytics.lastSearch = searchEvent.timestamp;
+
+ // Keep only last 1000 events to prevent memory issues
+ if (searchEvents.length > 1000) {
+ searchEvents.splice(0, searchEvents.length - 1000);
+ }
+}
+
+/**
+ * Record a rate limit hit
+ */
+export function recordRateLimitHit(provider: string): void {
+ analytics.rateLimitHits[provider] = (analytics.rateLimitHits[provider] || 0) + 1;
+}
+
+/**
+ * Get current analytics
+ */
+export function getAnalytics(): SearchAnalytics {
+ return { ...analytics };
+}
+
+/**
+ * Get search events (with optional filtering)
+ */
+export function getSearchEvents(limit?: number): SearchEvent[] {
+ const events = [...searchEvents].reverse(); // Most recent first
+ return limit ? events.slice(0, limit) : events;
+}
+
+/**
+ * Get analytics for a specific time period
+ */
+export function getAnalyticsForPeriod(hours: number): Partial {
+ const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000);
+ const recentEvents = searchEvents.filter(event => event.timestamp >= cutoff);
+
+ if (recentEvents.length === 0) {
+ return {
+ totalSearches: 0,
+ successfulSearches: 0,
+ failedSearches: 0,
+ providerUsage: {},
+ queryTypes: {},
+ averageResponseTime: 0
+ };
+ }
+
+ const successful = recentEvents.filter(e => e.success).length;
+ const failed = recentEvents.length - successful;
+
+ const providerUsage: Record = {};
+ const queryTypes: Record = {};
+
+ recentEvents.forEach(event => {
+ providerUsage[event.provider] = (providerUsage[event.provider] || 0) + 1;
+ const queryType = categorizeQuery(event.query);
+ queryTypes[queryType] = (queryTypes[queryType] || 0) + 1;
+ });
+
+ const avgResponseTime = recentEvents.reduce((sum, e) => sum + e.responseTime, 0) / recentEvents.length;
+
+ return {
+ totalSearches: recentEvents.length,
+ successfulSearches: successful,
+ failedSearches: failed,
+ providerUsage,
+ queryTypes,
+ averageResponseTime: avgResponseTime
+ };
+}
+
+/**
+ * Categorize a query for analytics
+ */
+function categorizeQuery(query: string): string {
+ const lowerQuery = query.toLowerCase();
+
+ if (lowerQuery.includes('who is') || lowerQuery.includes('who was')) {
+ return 'person';
+ } else if (lowerQuery.includes('what is') || lowerQuery.includes('what are')) {
+ return 'definition';
+ } else if (lowerQuery.includes('how to') || lowerQuery.includes('how do')) {
+ return 'tutorial';
+ } else if (lowerQuery.includes('latest') || lowerQuery.includes('recent') || lowerQuery.includes('news')) {
+ return 'news';
+ } else if (lowerQuery.includes('weather') || lowerQuery.includes('temperature')) {
+ return 'weather';
+ } else if (lowerQuery.includes('price') || lowerQuery.includes('cost') || lowerQuery.includes('buy')) {
+ return 'shopping';
+ } else {
+ return 'general';
+ }
+}
+
+/**
+ * Get provider performance metrics
+ */
+export function getProviderPerformance(): Record {
+ const providerStats: Record = {};
+
+ Object.keys(analytics.providerUsage).forEach(provider => {
+ const providerEvents = searchEvents.filter(e => e.provider === provider);
+ const successful = providerEvents.filter(e => e.success).length;
+ const total = providerEvents.length;
+ const avgTime = providerEvents.reduce((sum, e) => sum + e.responseTime, 0) / total;
+ const lastUsed = providerEvents.length > 0 ? providerEvents[providerEvents.length - 1].timestamp : null;
+
+ providerStats[provider] = {
+ successRate: total > 0 ? (successful / total) * 100 : 0,
+ averageResponseTime: avgTime,
+ totalSearches: total,
+ lastUsed
+ };
+ });
+
+ return providerStats;
+}
+
+/**
+ * Reset analytics (useful for testing)
+ */
+export function resetAnalytics(): void {
+ analytics.totalSearches = 0;
+ analytics.successfulSearches = 0;
+ analytics.failedSearches = 0;
+ analytics.providerUsage = {};
+ analytics.queryTypes = {};
+ analytics.averageResponseTime = 0;
+ analytics.lastSearch = null;
+ analytics.rateLimitHits = {};
+ searchEvents.length = 0;
+}
+
+/**
+ * Export analytics to JSON
+ */
+export function exportAnalytics(): string {
+ return JSON.stringify({
+ analytics,
+ events: searchEvents,
+ exportedAt: new Date().toISOString()
+ }, null, 2);
+}
+
diff --git a/src/lib/server/webSearch/config.ts b/src/lib/server/webSearch/config.ts
new file mode 100644
index 00000000000..a633f0e8078
--- /dev/null
+++ b/src/lib/server/webSearch/config.ts
@@ -0,0 +1,110 @@
+export interface SearchProviderConfig {
+ name: string;
+ enabled: boolean;
+ priority: number;
+ rateLimit?: {
+ requestsPerMinute: number;
+ requestsPerDay: number;
+ };
+ apiKey?: string;
+ additionalConfig?: Record;
+}
+
+export interface WebSearchConfig {
+ providers: Record;
+ fallbackToMock: boolean;
+ maxResults: number;
+ timeout: number;
+ cacheEnabled: boolean;
+ cacheTTL: number; // in seconds
+}
+
+// Default configuration
+export const defaultWebSearchConfig: WebSearchConfig = {
+ providers: {
+ google: {
+ name: "Google Custom Search",
+ enabled: true,
+ priority: 1,
+ rateLimit: {
+ requestsPerMinute: 10,
+ requestsPerDay: 100,
+ },
+ apiKey: process.env.GOOGLE_SEARCH_API_KEY,
+ additionalConfig: {
+ searchEngineId: process.env.GOOGLE_SEARCH_ENGINE_ID,
+ },
+ },
+ exa: {
+ name: "Exa MCP",
+ enabled: true,
+ priority: 2,
+ rateLimit: {
+ requestsPerMinute: 20,
+ requestsPerDay: 1000,
+ },
+ apiKey: process.env.EXA_API_KEY,
+ additionalConfig: {
+ mcpEndpoint: process.env.EXA_MCP_ENDPOINT || "https://mcp.exa.ai/mcp",
+ },
+ },
+ bing: {
+ name: "Bing Search API",
+ enabled: true,
+ priority: 3,
+ rateLimit: {
+ requestsPerMinute: 15,
+ requestsPerDay: 1000,
+ },
+ apiKey: process.env.BING_SEARCH_API_KEY,
+ },
+ serpapi: {
+ name: "SerpAPI",
+ enabled: true,
+ priority: 4,
+ rateLimit: {
+ requestsPerMinute: 20,
+ requestsPerDay: 100,
+ },
+ apiKey: process.env.SERPAPI_API_KEY,
+ },
+ duckduckgo: {
+ name: "DuckDuckGo",
+ enabled: true,
+ priority: 5,
+ rateLimit: {
+ requestsPerMinute: 30,
+ requestsPerDay: 1000,
+ },
+ },
+ brave: {
+ name: "Brave Search API",
+ enabled: true,
+ priority: 6,
+ rateLimit: {
+ requestsPerMinute: 20,
+ requestsPerDay: 2000,
+ },
+ apiKey: process.env.BRAVE_SEARCH_API_KEY,
+ },
+ },
+ fallbackToMock: true,
+ maxResults: 10,
+ timeout: 10000, // 10 seconds
+ cacheEnabled: true,
+ cacheTTL: 300, // 5 minutes
+};
+
+// Get enabled providers sorted by priority
+export function getEnabledProviders(
+ config: WebSearchConfig = defaultWebSearchConfig
+): SearchProviderConfig[] {
+ return Object.values(config.providers)
+ .filter((provider) => provider.enabled && provider.apiKey)
+ .sort((a, b) => a.priority - b.priority);
+}
+
+// Check if any provider is available
+export function hasAvailableProviders(config: WebSearchConfig = defaultWebSearchConfig): boolean {
+ return getEnabledProviders(config).length > 0;
+}
diff --git a/src/lib/server/webSearch/dashboard.ts b/src/lib/server/webSearch/dashboard.ts
new file mode 100644
index 00000000000..9c0d90b066c
--- /dev/null
+++ b/src/lib/server/webSearch/dashboard.ts
@@ -0,0 +1,229 @@
+import { getAnalytics, getSearchEvents, getProviderPerformance, getAnalyticsForPeriod } from "./analytics";
+import { getEnabledProviders } from "./config";
+
+/**
+ * Web Search Dashboard - Monitor and analyze search performance
+ */
+export class WebSearchDashboard {
+ /**
+ * Get a comprehensive dashboard overview
+ */
+ static getOverview() {
+ const analytics = getAnalytics();
+ const providerPerformance = getProviderPerformance();
+ const recentEvents = getSearchEvents(10);
+ const last24Hours = getAnalyticsForPeriod(24);
+
+ return {
+ summary: {
+ totalSearches: analytics.totalSearches,
+ successRate: analytics.totalSearches > 0
+ ? ((analytics.successfulSearches / analytics.totalSearches) * 100).toFixed(1) + '%'
+ : '0%',
+ averageResponseTime: Math.round(analytics.averageResponseTime) + 'ms',
+ lastSearch: analytics.lastSearch?.toISOString() || 'Never'
+ },
+ providers: Object.entries(providerPerformance).map(([name, stats]) => ({
+ name,
+ ...stats,
+ successRate: stats.successRate.toFixed(1) + '%',
+ averageResponseTime: Math.round(stats.averageResponseTime) + 'ms',
+ lastUsed: stats.lastUsed?.toISOString() || 'Never'
+ })),
+ recentActivity: recentEvents.map(event => ({
+ timestamp: event.timestamp.toISOString(),
+ query: event.query,
+ provider: event.provider,
+ success: event.success,
+ responseTime: event.responseTime + 'ms',
+ resultCount: event.resultCount,
+ error: event.error
+ })),
+ last24Hours: {
+ searches: last24Hours.totalSearches || 0,
+ successRate: last24Hours.totalSearches > 0
+ ? (((last24Hours.successfulSearches || 0) / last24Hours.totalSearches) * 100).toFixed(1) + '%'
+ : '0%',
+ averageResponseTime: Math.round(last24Hours.averageResponseTime || 0) + 'ms'
+ },
+ queryTypes: analytics.queryTypes,
+ rateLimitHits: analytics.rateLimitHits
+ };
+ }
+
+ /**
+ * Get provider health status
+ */
+ static getProviderHealth() {
+ const enabledProviders = getEnabledProviders();
+ const providerPerformance = getProviderPerformance();
+
+ return enabledProviders.map(provider => {
+ const stats = providerPerformance[provider.name];
+ const isHealthy = stats ? stats.successRate > 80 : false;
+ const isActive = stats ? stats.totalSearches > 0 : false;
+
+ return {
+ name: provider.name,
+ enabled: provider.enabled,
+ healthy: isHealthy,
+ active: isActive,
+ successRate: stats?.successRate.toFixed(1) + '%' || 'N/A',
+ totalSearches: stats?.totalSearches || 0,
+ lastUsed: stats?.lastUsed?.toISOString() || 'Never',
+ rateLimit: provider.rateLimit
+ };
+ });
+ }
+
+ /**
+ * Get search trends over time
+ */
+ static getSearchTrends(hours: number = 24) {
+ const events = getSearchEvents();
+ const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000);
+ const recentEvents = events.filter(event => event.timestamp >= cutoff);
+
+ // Group by hour
+ const hourlyData: Record = {};
+
+ recentEvents.forEach(event => {
+ const hour = event.timestamp.toISOString().slice(0, 13) + ':00:00';
+ if (!hourlyData[hour]) {
+ hourlyData[hour] = { searches: 0, successes: 0 };
+ }
+ hourlyData[hour].searches++;
+ if (event.success) {
+ hourlyData[hour].successes++;
+ }
+ });
+
+ return Object.entries(hourlyData).map(([hour, data]) => ({
+ hour,
+ searches: data.searches,
+ successes: data.successes,
+ successRate: data.searches > 0 ? ((data.successes / data.searches) * 100).toFixed(1) + '%' : '0%'
+ }));
+ }
+
+ /**
+ * Get top queries
+ */
+ static getTopQueries(limit: number = 10) {
+ const events = getSearchEvents();
+ const queryCounts: Record = {};
+
+ events.forEach(event => {
+ queryCounts[event.query] = (queryCounts[event.query] || 0) + 1;
+ });
+
+ return Object.entries(queryCounts)
+ .sort(([,a], [,b]) => b - a)
+ .slice(0, limit)
+ .map(([query, count]) => ({ query, count }));
+ }
+
+ /**
+ * Get error analysis
+ */
+ static getErrorAnalysis() {
+ const events = getSearchEvents();
+ const errors: Record = {};
+
+ events.filter(event => !event.success && event.error).forEach(event => {
+ const errorType = event.error?.includes('rate limit') ? 'Rate Limit' :
+ event.error?.includes('API key') ? 'API Key' :
+ event.error?.includes('network') ? 'Network' :
+ event.error?.includes('timeout') ? 'Timeout' : 'Other';
+ errors[errorType] = (errors[errorType] || 0) + 1;
+ });
+
+ return Object.entries(errors).map(([errorType, count]) => ({
+ errorType,
+ count,
+ percentage: events.length > 0 ? ((count / events.length) * 100).toFixed(1) + '%' : '0%'
+ }));
+ }
+
+ /**
+ * Generate a health report
+ */
+ static generateHealthReport() {
+ const overview = this.getOverview();
+ const providerHealth = this.getProviderHealth();
+ const errorAnalysis = this.getErrorAnalysis();
+
+ const report = {
+ generatedAt: new Date().toISOString(),
+ overallHealth: this.calculateOverallHealth(overview, providerHealth),
+ recommendations: this.generateRecommendations(overview, providerHealth, errorAnalysis),
+ overview,
+ providerHealth,
+ errorAnalysis
+ };
+
+ return report;
+ }
+
+ /**
+ * Calculate overall health score (0-100)
+ */
+ private static calculateOverallHealth(overview: any, providerHealth: any[]): number {
+ const successRate = parseFloat(overview.summary.successRate);
+ const activeProviders = providerHealth.filter(p => p.active).length;
+ const totalProviders = providerHealth.length;
+ const healthyProviders = providerHealth.filter(p => p.healthy).length;
+
+ const healthScore = (
+ (successRate * 0.4) + // 40% weight on success rate
+ ((activeProviders / totalProviders) * 100 * 0.3) + // 30% weight on active providers
+ ((healthyProviders / totalProviders) * 100 * 0.3) // 30% weight on healthy providers
+ );
+
+ return Math.round(healthScore);
+ }
+
+ /**
+ * Generate recommendations based on analytics
+ */
+ private static generateRecommendations(overview: any, providerHealth: any[], errorAnalysis: any[]): string[] {
+ const recommendations: string[] = [];
+
+ // Check success rate
+ const successRate = parseFloat(overview.summary.successRate);
+ if (successRate < 80) {
+ recommendations.push("โ ๏ธ Low success rate detected. Consider adding more search providers or checking API configurations.");
+ }
+
+ // Check response time
+ const avgResponseTime = parseInt(overview.summary.averageResponseTime);
+ if (avgResponseTime > 5000) {
+ recommendations.push("๐ Slow response times detected. Consider optimizing search providers or adding caching.");
+ }
+
+ // Check provider health
+ const unhealthyProviders = providerHealth.filter(p => !p.healthy && p.active);
+ if (unhealthyProviders.length > 0) {
+ recommendations.push(`๐ง ${unhealthyProviders.length} provider(s) showing poor performance: ${unhealthyProviders.map(p => p.name).join(', ')}`);
+ }
+
+ // Check rate limits
+ const rateLimitHits = Object.values(overview.rateLimitHits).reduce((sum: number, count: any) => sum + count, 0);
+ if (rateLimitHits > 0) {
+ recommendations.push("๐ Rate limits being hit. Consider upgrading API plans or adding more providers.");
+ }
+
+ // Check error patterns
+ const rateLimitErrors = errorAnalysis.find(e => e.errorType === 'Rate Limit');
+ if (rateLimitErrors && rateLimitErrors.count > 0) {
+ recommendations.push("๐ซ Rate limit errors detected. Consider implementing better rate limiting or adding more providers.");
+ }
+
+ if (recommendations.length === 0) {
+ recommendations.push("โ
All systems operating normally!");
+ }
+
+ return recommendations;
+ }
+}
+
diff --git a/src/lib/server/webSearch/patterns.ts b/src/lib/server/webSearch/patterns.ts
new file mode 100644
index 00000000000..d246c85dae1
--- /dev/null
+++ b/src/lib/server/webSearch/patterns.ts
@@ -0,0 +1,148 @@
+export interface SearchPattern {
+ pattern: RegExp;
+ extractQuery: (match: RegExpMatchArray, content: string) => string;
+ priority: number;
+ description: string;
+}
+
+/**
+ * Customizable web search detection patterns
+ * Add, modify, or remove patterns as needed
+ */
+export const searchPatterns: SearchPattern[] = [
+ {
+ pattern: /๐.*using web search\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 1,
+ description: "Globe emoji with 'using web search'"
+ },
+ {
+ pattern: /web search\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 2,
+ description: "Simple 'web search' prefix"
+ },
+ {
+ pattern: /search the web\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 3,
+ description: "Search the web prefix"
+ },
+ {
+ pattern: /find information about\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 4,
+ description: "Find information about prefix"
+ },
+ {
+ pattern: /latest information about\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 5,
+ description: "Latest information about prefix"
+ },
+ {
+ pattern: /current news about\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 6,
+ description: "Current news about prefix"
+ },
+ {
+ pattern: /look up\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 7,
+ description: "Look up prefix"
+ },
+ {
+ pattern: /search for\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 8,
+ description: "Search for prefix"
+ },
+ {
+ pattern: /what is\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 9,
+ description: "What is question"
+ },
+ {
+ pattern: /who is\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 10,
+ description: "Who is question"
+ },
+ {
+ pattern: /tell me about\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 11,
+ description: "Tell me about prefix"
+ },
+ {
+ pattern: /explain\s+(.+)/i,
+ extractQuery: (match) => match[1].trim(),
+ priority: 12,
+ description: "Explain prefix"
+ }
+];
+
+/**
+ * Enhanced web search detection with customizable patterns
+ */
+export function detectWebSearchRequest(content: string): string | null {
+ // Sort patterns by priority (lower number = higher priority)
+ const sortedPatterns = [...searchPatterns].sort((a, b) => a.priority - b.priority);
+
+ for (const { pattern, extractQuery } of sortedPatterns) {
+ const match = content.match(pattern);
+ if (match) {
+ const query = extractQuery(match, content);
+ if (query && query.length > 0) {
+ return query;
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Add a custom search pattern
+ */
+export function addSearchPattern(pattern: SearchPattern): void {
+ searchPatterns.push(pattern);
+ // Re-sort by priority
+ searchPatterns.sort((a, b) => a.priority - b.priority);
+}
+
+/**
+ * Remove a search pattern by description
+ */
+export function removeSearchPattern(description: string): boolean {
+ const index = searchPatterns.findIndex(p => p.description === description);
+ if (index !== -1) {
+ searchPatterns.splice(index, 1);
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Get all available patterns
+ */
+export function getSearchPatterns(): SearchPattern[] {
+ return [...searchPatterns];
+}
+
+/**
+ * Test patterns against sample messages
+ */
+export function testPatterns(sampleMessages: string[]): void {
+ console.log("๐งช Testing Search Patterns");
+ console.log("==========================");
+
+ sampleMessages.forEach((message, index) => {
+ const query = detectWebSearchRequest(message);
+ console.log(`${index + 1}. "${message}"`);
+ console.log(` โ ${query ? `โ
Detected: "${query}"` : "โ No search detected"}`);
+ });
+}
+
diff --git a/src/lib/server/webSearch/searchProviders.ts b/src/lib/server/webSearch/searchProviders.ts
new file mode 100644
index 00000000000..59c4f06f207
--- /dev/null
+++ b/src/lib/server/webSearch/searchProviders.ts
@@ -0,0 +1,396 @@
+import type { WebSearchResult, WebSearchResponse } from "./webSearchService";
+import { defaultWebSearchConfig, type SearchProviderConfig } from "./config";
+
+// Rate limiting storage
+const rateLimitStore = new Map<
+ string,
+ { requests: number[]; dailyRequests: number; lastReset: Date }
+>();
+
+/**
+ * Check rate limits for a provider
+ */
+function checkRateLimit(provider: SearchProviderConfig): boolean {
+ const now = new Date();
+ const key = provider.name.toLowerCase();
+ const store = rateLimitStore.get(key) || { requests: [], dailyRequests: 0, lastReset: now };
+
+ // Reset daily counter if it's a new day
+ if (now.getDate() !== store.lastReset.getDate()) {
+ store.dailyRequests = 0;
+ store.lastReset = now;
+ }
+
+ // Check daily limit
+ if (store.dailyRequests >= (provider.rateLimit?.requestsPerDay || Infinity)) {
+ return false;
+ }
+
+ // Check per-minute limit
+ const oneMinuteAgo = new Date(now.getTime() - 60000);
+ store.requests = store.requests.filter((time) => time > oneMinuteAgo.getTime());
+
+ if (store.requests.length >= (provider.rateLimit?.requestsPerMinute || Infinity)) {
+ return false;
+ }
+
+ // Record this request
+ store.requests.push(now.getTime());
+ store.dailyRequests++;
+ rateLimitStore.set(key, store);
+
+ return true;
+}
+
+/**
+ * Google Custom Search API implementation
+ * Requires GOOGLE_SEARCH_API_KEY and GOOGLE_SEARCH_ENGINE_ID environment variables
+ */
+export async function searchWithGoogle(
+ query: string,
+ config?: SearchProviderConfig
+): Promise {
+ const apiKey = config?.apiKey || process.env.GOOGLE_SEARCH_API_KEY;
+ const searchEngineId =
+ config?.additionalConfig?.searchEngineId || process.env.GOOGLE_SEARCH_ENGINE_ID;
+
+ if (!apiKey || !searchEngineId) {
+ throw new Error("Google Search API credentials not configured");
+ }
+
+ // Check rate limits
+ if (config && !checkRateLimit(config)) {
+ throw new Error("Google Search API rate limit exceeded");
+ }
+
+ const url = new URL("https://www.googleapis.com/customsearch/v1");
+ url.searchParams.set("key", apiKey);
+ url.searchParams.set("cx", searchEngineId);
+ url.searchParams.set("q", query);
+ url.searchParams.set("num", "10"); // Limit to 10 results
+
+ const response = await fetch(url.toString());
+
+ if (!response.ok) {
+ throw new Error(`Google Search API error: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ const results: WebSearchResult[] = (data.items || []).map((item: any) => ({
+ title: item.title,
+ link: item.link,
+ snippet: item.snippet || "",
+ }));
+
+ return {
+ results,
+ query,
+ };
+}
+
+/**
+ * Bing Search API implementation
+ * Requires BING_SEARCH_API_KEY environment variable
+ */
+export async function searchWithBing(query: string): Promise {
+ const apiKey = process.env.BING_SEARCH_API_KEY;
+
+ if (!apiKey) {
+ throw new Error("Bing Search API key not configured");
+ }
+
+ const url = new URL("https://api.bing.microsoft.com/v7.0/search");
+ url.searchParams.set("q", query);
+ url.searchParams.set("count", "10");
+
+ const response = await fetch(url.toString(), {
+ headers: {
+ "Ocp-Apim-Subscription-Key": apiKey,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Bing Search API error: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ const results: WebSearchResult[] = (data.webPages?.value || []).map((item: any) => ({
+ title: item.name,
+ link: item.url,
+ snippet: item.snippet || "",
+ }));
+
+ return {
+ results,
+ query,
+ };
+}
+
+/**
+ * SerpAPI implementation
+ * Requires SERPAPI_API_KEY environment variable
+ */
+export async function searchWithSerpAPI(query: string): Promise {
+ const apiKey = process.env.SERPAPI_API_KEY;
+
+ if (!apiKey) {
+ throw new Error("SerpAPI key not configured");
+ }
+
+ const url = new URL("https://serpapi.com/search");
+ url.searchParams.set("api_key", apiKey);
+ url.searchParams.set("q", query);
+ url.searchParams.set("engine", "google");
+ url.searchParams.set("num", "10");
+
+ const response = await fetch(url.toString());
+
+ if (!response.ok) {
+ throw new Error(`SerpAPI error: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ const results: WebSearchResult[] = (data.organic_results || []).map((item: any) => ({
+ title: item.title,
+ link: item.link,
+ snippet: item.snippet || "",
+ }));
+
+ return {
+ results,
+ query,
+ };
+}
+
+/**
+ * DuckDuckGo Search API implementation (Free, no API key required)
+ */
+export async function searchWithDuckDuckGo(
+ query: string,
+ config?: SearchProviderConfig
+): Promise {
+ // Check rate limits
+ if (config && !checkRateLimit(config)) {
+ throw new Error("DuckDuckGo Search API rate limit exceeded");
+ }
+
+ const url = new URL("https://api.duckduckgo.com/");
+ url.searchParams.set("q", query);
+ url.searchParams.set("format", "json");
+ url.searchParams.set("no_html", "1");
+ url.searchParams.set("skip_disambig", "1");
+
+ const response = await fetch(url.toString(), {
+ headers: {
+ "User-Agent": "ChatUI-WebSearch/1.0",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`DuckDuckGo Search API error: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ const results: WebSearchResult[] = [];
+
+ // Add instant answer if available
+ if (data.AbstractText) {
+ results.push({
+ title: data.Heading || "Instant Answer",
+ link: data.AbstractURL || "",
+ snippet: data.AbstractText,
+ });
+ }
+
+ // Add related topics
+ if (data.RelatedTopics) {
+ data.RelatedTopics.slice(0, 5).forEach((topic: any) => {
+ if (topic.Text && topic.FirstURL) {
+ results.push({
+ title: topic.Text.split(" - ")[0] || topic.Text,
+ link: topic.FirstURL,
+ snippet: topic.Text,
+ });
+ }
+ });
+ }
+
+ return {
+ results: results.slice(0, 10),
+ query,
+ };
+}
+
+/**
+ * Brave Search API implementation
+ * Requires BRAVE_SEARCH_API_KEY environment variable
+ */
+export async function searchWithBrave(
+ query: string,
+ config?: SearchProviderConfig
+): Promise {
+ const apiKey = config?.apiKey || process.env.BRAVE_SEARCH_API_KEY;
+
+ if (!apiKey) {
+ throw new Error("Brave Search API key not configured");
+ }
+
+ // Check rate limits
+ if (config && !checkRateLimit(config)) {
+ throw new Error("Brave Search API rate limit exceeded");
+ }
+
+ const url = new URL("https://api.search.brave.com/res/v1/web/search");
+ url.searchParams.set("q", query);
+ url.searchParams.set("count", "10");
+
+ const response = await fetch(url.toString(), {
+ headers: {
+ "X-Subscription-Token": apiKey,
+ Accept: "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Brave Search API error: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ const results: WebSearchResult[] = (data.web?.results || []).map((item: any) => ({
+ title: item.title,
+ link: item.url,
+ snippet: item.description || "",
+ }));
+
+ return {
+ results,
+ query,
+ };
+}
+
+/**
+ * You.com Search API implementation
+ * Requires YOUCOM_API_KEY environment variable
+ */
+export async function searchWithYouCom(
+ query: string,
+ config?: SearchProviderConfig
+): Promise {
+ const apiKey = config?.apiKey || process.env.YOUCOM_API_KEY;
+
+ if (!apiKey) {
+ throw new Error("You.com API key not configured");
+ }
+
+ // Check rate limits
+ if (config && !checkRateLimit(config)) {
+ throw new Error("You.com API rate limit exceeded");
+ }
+
+ const url = new URL("https://api.ydc-index.io/search");
+ url.searchParams.set("query", query);
+ url.searchParams.set("num_web_results", "10");
+
+ const response = await fetch(url.toString(), {
+ headers: {
+ "X-API-Key": apiKey,
+ Accept: "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`You.com API error: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ const results: WebSearchResult[] = (data.hits || []).map((item: any) => ({
+ title: item.title,
+ link: item.url,
+ snippet: item.snippet || "",
+ }));
+
+ return {
+ results,
+ query,
+ };
+}
+
+/**
+ * Exa MCP Search implementation (via Smithery)
+ * Requires EXA_API_KEY environment variable
+ * Uses the Exa AI MCP server from Smithery (https://smithery.ai/server/exa)
+ *
+ * This provider uses the web_search_exa tool from the Exa MCP server which provides:
+ * - Real-time web searches powered by Exa AI
+ * - High-quality, relevant results
+ * - Content scraping from specific URLs
+ * - Configurable result counts
+ */
+export async function searchWithExaMCP(
+ query: string,
+ config?: SearchProviderConfig
+): Promise {
+ const apiKey = config?.apiKey || process.env.EXA_API_KEY;
+
+ if (!apiKey) {
+ throw new Error("Exa API key not configured");
+ }
+
+ // Check rate limits
+ if (config && !checkRateLimit(config)) {
+ throw new Error("Exa MCP API rate limit exceeded");
+ }
+
+ // Exa MCP uses a different endpoint structure
+ // The MCP server endpoint from Smithery
+ const mcpEndpoint = config?.additionalConfig?.mcpEndpoint || "https://mcp.exa.ai/mcp";
+
+ // Call the Exa search API directly
+ // Based on Exa's API documentation: https://docs.exa.ai/reference/search
+ const url = new URL("https://api.exa.ai/search");
+
+ const requestBody = {
+ query: query,
+ numResults: 10,
+ type: "neural", // Use neural search for better results
+ contents: {
+ text: true,
+ highlights: true,
+ },
+ };
+
+ const response = await fetch(url.toString(), {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": apiKey,
+ Accept: "application/json",
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Exa MCP API error: ${response.status} - ${errorText}`);
+ }
+
+ const data = await response.json();
+
+ // Transform Exa results to our format
+ const results: WebSearchResult[] = (data.results || []).map((item: any) => ({
+ title: item.title || "Untitled",
+ link: item.url,
+ snippet: item.text || item.highlights?.[0] || item.summary || "",
+ }));
+
+ return {
+ results,
+ query,
+ };
+}
diff --git a/src/lib/server/webSearch/test.ts b/src/lib/server/webSearch/test.ts
new file mode 100644
index 00000000000..6d30760cbdb
--- /dev/null
+++ b/src/lib/server/webSearch/test.ts
@@ -0,0 +1,139 @@
+import { performWebSearch, detectWebSearchRequest } from "./webSearchService";
+import { defaultWebSearchConfig } from "./config";
+import { testPatterns } from "./patterns";
+import { WebSearchDashboard } from "./dashboard";
+import { resetAnalytics } from "./analytics";
+
+/**
+ * Test script for web search functionality
+ * Run with: npx tsx src/lib/server/webSearch/test.ts
+ */
+
+async function testWebSearch() {
+ console.log("๐งช Testing Enhanced Web Search Implementation");
+ console.log("==============================================");
+
+ // Reset analytics for clean test
+ resetAnalytics();
+
+ // Test 1: Enhanced detection patterns
+ console.log("\n1. Testing enhanced detection patterns:");
+ const testMessages = [
+ "๐ Using web search who is david parnas",
+ "web search latest news about AI",
+ "search the web for information about climate change",
+ "find information about quantum computing",
+ "what is machine learning",
+ "who is alan turing",
+ "tell me about blockchain",
+ "explain quantum computing",
+ "regular message without search",
+ "๐ using web search what is machine learning"
+ ];
+
+ testPatterns(testMessages);
+
+ // Test 2: Web search execution with analytics
+ console.log("\n2. Testing web search execution with analytics:");
+ const testQueries = [
+ "who is david parnas",
+ "latest AI news",
+ "climate change facts",
+ "quantum computing basics",
+ "machine learning algorithms"
+ ];
+
+ for (const query of testQueries) {
+ console.log(`\n Testing query: "${query}"`);
+ try {
+ const startTime = Date.now();
+ const result = await performWebSearch(query);
+ const duration = Date.now() - startTime;
+
+ console.log(` โ
Success in ${duration}ms`);
+ console.log(` ๐ Found ${result.results.length} results`);
+ console.log(` ๐ First result: ${result.results[0]?.title || "None"}`);
+ console.log(` ๐ First link: ${result.results[0]?.link || "None"}`);
+ } catch (error) {
+ console.log(` โ Failed: ${error}`);
+ }
+ }
+
+ // Test 3: Configuration and providers
+ console.log("\n3. Testing configuration and providers:");
+ console.log(` ๐ Available providers: ${Object.keys(defaultWebSearchConfig.providers).length}`);
+ console.log(` ๐ง Max results: ${defaultWebSearchConfig.maxResults}`);
+ console.log(` โฑ๏ธ Timeout: ${defaultWebSearchConfig.timeout}ms`);
+ console.log(` ๐พ Cache enabled: ${defaultWebSearchConfig.cacheEnabled}`);
+
+ // Test 4: Rate limiting and monitoring
+ console.log("\n4. Testing rate limiting and monitoring:");
+ console.log(" ๐ Rate limits configured:");
+ Object.entries(defaultWebSearchConfig.providers).forEach(([name, config]) => {
+ if (config.enabled) {
+ console.log(` ${name}: ${config.rateLimit?.requestsPerMinute || "unlimited"}/min, ${config.rateLimit?.requestsPerDay || "unlimited"}/day`);
+ }
+ });
+
+ // Test 5: Analytics and dashboard
+ console.log("\n5. Testing analytics and dashboard:");
+ const dashboard = WebSearchDashboard.getOverview();
+ console.log(` ๐ Total searches: ${dashboard.summary.totalSearches}`);
+ console.log(` โ
Success rate: ${dashboard.summary.successRate}`);
+ console.log(` โฑ๏ธ Average response time: ${dashboard.summary.averageResponseTime}`);
+ console.log(` ๐ Last search: ${dashboard.summary.lastSearch}`);
+
+ // Test 6: Provider health
+ console.log("\n6. Testing provider health:");
+ const providerHealth = WebSearchDashboard.getProviderHealth();
+ providerHealth.forEach(provider => {
+ console.log(` ${provider.name}: ${provider.healthy ? 'โ
' : 'โ'} ${provider.successRate} (${provider.totalSearches} searches)`);
+ });
+
+ // Test 7: Search trends
+ console.log("\n7. Testing search trends:");
+ const trends = WebSearchDashboard.getSearchTrends(1); // Last hour
+ console.log(` ๐ Searches in last hour: ${trends.length > 0 ? trends.reduce((sum, t) => sum + t.searches, 0) : 0}`);
+
+ // Test 8: Top queries
+ console.log("\n8. Testing top queries:");
+ const topQueries = WebSearchDashboard.getTopQueries(5);
+ topQueries.forEach((query, index) => {
+ console.log(` ${index + 1}. "${query.query}" (${query.count} times)`);
+ });
+
+ // Test 9: Error analysis
+ console.log("\n9. Testing error analysis:");
+ const errorAnalysis = WebSearchDashboard.getErrorAnalysis();
+ if (errorAnalysis.length > 0) {
+ errorAnalysis.forEach(error => {
+ console.log(` ${error.errorType}: ${error.count} (${error.percentage})`);
+ });
+ } else {
+ console.log(" โ
No errors detected");
+ }
+
+ // Test 10: Health report
+ console.log("\n10. Generating health report:");
+ const healthReport = WebSearchDashboard.generateHealthReport();
+ console.log(` ๐ฅ Overall health: ${healthReport.overallHealth}/100`);
+ console.log(" ๐ก Recommendations:");
+ healthReport.recommendations.forEach(rec => {
+ console.log(` ${rec}`);
+ });
+
+ console.log("\nโ
Enhanced web search test completed!");
+ console.log("\n๐ Next steps:");
+ console.log(" 1. Set up at least one API key in your .env file");
+ console.log(" 2. Test with real queries in your chat interface");
+ console.log(" 3. Monitor the dashboard for search analytics");
+ console.log(" 4. Customize detection patterns if needed");
+ console.log(" 5. Set up monitoring and alerting for production");
+}
+
+// Run the test if this file is executed directly
+if (import.meta.url === `file://${process.argv[1]}`) {
+ testWebSearch().catch(console.error);
+}
+
+export { testWebSearch };
diff --git a/src/lib/server/webSearch/webSearchService.ts b/src/lib/server/webSearch/webSearchService.ts
new file mode 100644
index 00000000000..4330d16810a
--- /dev/null
+++ b/src/lib/server/webSearch/webSearchService.ts
@@ -0,0 +1,146 @@
+import {
+ searchWithGoogle,
+ searchWithBing,
+ searchWithSerpAPI,
+ searchWithDuckDuckGo,
+ searchWithBrave,
+ searchWithYouCom,
+ searchWithExaMCP,
+} from "./searchProviders";
+import { defaultWebSearchConfig, getEnabledProviders, hasAvailableProviders } from "./config";
+import { detectWebSearchRequest as detectWithPatterns } from "./patterns";
+import { recordSearchEvent, recordRateLimitHit } from "./analytics";
+
+export interface WebSearchResult {
+ title: string;
+ link: string;
+ snippet: string;
+}
+
+export interface WebSearchResponse {
+ results: WebSearchResult[];
+ query: string;
+}
+
+/**
+ * Performs web search using multiple search APIs with intelligent fallback
+ * Supports: Google, Bing, SerpAPI, DuckDuckGo, Brave, You.com
+ */
+export async function performWebSearch(
+ query: string,
+ config = defaultWebSearchConfig
+): Promise {
+ console.log(`Performing web search for: ${query}`);
+
+ // Check if any providers are available
+ if (!hasAvailableProviders(config)) {
+ console.warn("No search providers configured, using mock data");
+ return getMockSearchResults(query);
+ }
+
+ // Get enabled providers in priority order
+ const enabledProviders = getEnabledProviders(config);
+
+ // Map provider names to their functions
+ const providerFunctions = {
+ google: searchWithGoogle,
+ exa: searchWithExaMCP,
+ bing: searchWithBing,
+ serpapi: searchWithSerpAPI,
+ duckduckgo: searchWithDuckDuckGo,
+ brave: searchWithBrave,
+ youcom: searchWithYouCom,
+ };
+
+ // Try each provider in order of priority
+ for (const provider of enabledProviders) {
+ const startTime = Date.now();
+ try {
+ const providerKey = provider.name.toLowerCase().replace(/\s+/g, "");
+ const searchFunction = providerFunctions[providerKey as keyof typeof providerFunctions];
+
+ if (!searchFunction) {
+ console.warn(`No function found for provider: ${provider.name}`);
+ continue;
+ }
+
+ console.log(`Trying ${provider.name} search...`);
+ const result = await searchFunction(query, provider);
+ const responseTime = Date.now() - startTime;
+
+ // Record successful search
+ recordSearchEvent({
+ query,
+ provider: provider.name,
+ success: true,
+ responseTime,
+ resultCount: result.results.length,
+ });
+
+ console.log(
+ `Found ${result.results.length} results with ${provider.name} in ${responseTime}ms`
+ );
+ return result;
+ } catch (error) {
+ const responseTime = Date.now() - startTime;
+ const errorMessage = error instanceof Error ? error.message : String(error);
+
+ // Check if it's a rate limit error
+ if (errorMessage.includes("rate limit")) {
+ recordRateLimitHit(provider.name);
+ }
+
+ // Record failed search
+ recordSearchEvent({
+ query,
+ provider: provider.name,
+ success: false,
+ responseTime,
+ resultCount: 0,
+ error: errorMessage,
+ });
+
+ console.warn(`${provider.name} search failed:`, error);
+ // Continue to next provider
+ }
+ }
+
+ // If all providers fail, return mock data
+ console.warn("All search providers failed, returning mock data");
+ return getMockSearchResults(query);
+}
+
+/**
+ * Returns mock search results for development/testing
+ */
+function getMockSearchResults(query: string): WebSearchResponse {
+ const mockResults: WebSearchResult[] = [
+ {
+ title: `Search Result 1 for "${query}"`,
+ link: "https://example.com/result1",
+ snippet: `This is a sample search result snippet for "${query}". It demonstrates how web search results would appear in the chat interface.`,
+ },
+ {
+ title: `Search Result 2 for "${query}"`,
+ link: "https://example.com/result2",
+ snippet: `Another sample search result snippet for "${query}". This shows how multiple results are handled.`,
+ },
+ {
+ title: `Search Result 3 for "${query}"`,
+ link: "https://example.com/result3",
+ snippet: `A third sample result for "${query}". This demonstrates the citation system with numbered references.`,
+ },
+ ];
+
+ return {
+ results: mockResults,
+ query,
+ };
+}
+
+/**
+ * Detects if a message contains web search requests using enhanced patterns
+ */
+export function detectWebSearchRequest(content: string): string | null {
+ return detectWithPatterns(content);
+}
diff --git a/src/lib/types/Message.ts b/src/lib/types/Message.ts
index 40eb3cd0ee2..21546210612 100644
--- a/src/lib/types/Message.ts
+++ b/src/lib/types/Message.ts
@@ -24,6 +24,9 @@ export type Message = Partial & {
provider?: InferenceProvider;
};
+ // Web search sources for citations
+ webSearchSources?: { title?: string; link: string }[];
+
// needed for conversation trees
ancestors?: Message["id"][];
diff --git a/src/lib/types/MessageUpdate.ts b/src/lib/types/MessageUpdate.ts
index 6400de02b56..0d5c2bf2ad0 100644
--- a/src/lib/types/MessageUpdate.ts
+++ b/src/lib/types/MessageUpdate.ts
@@ -7,7 +7,9 @@ export type MessageUpdate =
| MessageFileUpdate
| MessageFinalAnswerUpdate
| MessageReasoningUpdate
- | MessageRouterMetadataUpdate;
+ | MessageRouterMetadataUpdate
+ | MessageWebSearchUpdate
+ | MessageWebSearchSourcesUpdate;
export enum MessageUpdateType {
Status = "status",
@@ -17,6 +19,8 @@ export enum MessageUpdateType {
FinalAnswer = "finalAnswer",
Reasoning = "reasoning",
RouterMetadata = "routerMetadata",
+ WebSearch = "webSearch",
+ WebSearchSources = "webSearchSources",
}
// Status
@@ -78,3 +82,16 @@ export interface MessageRouterMetadataUpdate {
model: string;
provider?: InferenceProvider;
}
+
+// Web Search Updates
+export interface MessageWebSearchUpdate {
+ type: MessageUpdateType.WebSearch;
+ status: "searching" | "completed" | "error";
+ query: string;
+ message?: string;
+}
+
+export interface MessageWebSearchSourcesUpdate {
+ type: MessageUpdateType.WebSearchSources;
+ sources: { title?: string; link: string }[];
+}
diff --git a/tsconfig.ci.json b/tsconfig.ci.json
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tsconfig.json b/tsconfig.json
index 2e4b2d5d934..7c2bad77840 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,17 +1,28 @@
{
- "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
- "resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
- "target": "ES2018"
+ "target": "ES2018",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "lib": ["DOM", "DOM.Iterable", "ES6"]
},
- "exclude": ["vite.config.ts"]
+ "exclude": [
+ "vite.config.ts",
+ "postcss.config.js",
+ "svelte.config.js",
+ "tailwind.config.cjs",
+ "stub/**/*"
+ ]
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes