Compare commits
10 Commits
ab25981684
...
863f45f666
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
863f45f666 | ||
|
|
38e702ec2f | ||
|
|
92845a5a4c | ||
|
|
88015fbcae | ||
|
|
e0a602135a | ||
|
|
5f26029cfe | ||
|
|
d2920fd39a | ||
|
|
92b313fa79 | ||
|
|
2e9a5e9e7f | ||
|
|
27180aa2c3 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
.env
|
||||
.env
|
||||
logs
|
||||
99
README.md
Normal file
99
README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Search Agent
|
||||
|
||||
An intelligent search service with REST API, CLI, and MCP server interfaces.
|
||||
|
||||
## Features
|
||||
|
||||
- **REST API**: Full-featured HTTP API with streaming support
|
||||
- **CLI**: Command-line interface for quick searches
|
||||
- **MCP Server**: Model Context Protocol server for integration with AI assistants
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### REST API Server
|
||||
|
||||
```bash
|
||||
npm run serve
|
||||
```
|
||||
|
||||
The server will start at `http://localhost:3000`.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
npm run search "Your question here"
|
||||
```
|
||||
|
||||
### MCP Server
|
||||
|
||||
```bash
|
||||
npm run mcp
|
||||
```
|
||||
|
||||
## MCP Configuration
|
||||
|
||||
To use the search agent as an MCP server in your AI assistant, add this to your configuration:
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add to `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"search-agent": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/searchAgent/mcpServer.js"],
|
||||
"env": {
|
||||
"EXA_API_KEY": "your-exa-api-key",
|
||||
"OPENROUTER_API_KEY": "your-openrouter-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Other MCP Clients
|
||||
|
||||
For other MCP clients, configure the command as:
|
||||
- Command: `node`
|
||||
- Args: `["/path/to/searchAgent/mcpServer.js"]`
|
||||
- Environment variables: `EXA_API_KEY` and `OPENROUTER_API_KEY`
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
### search
|
||||
|
||||
Search for information and return a summarized answer with sources.
|
||||
|
||||
**Parameters:**
|
||||
- `question` (required): The question or topic to search for
|
||||
- `previousClarification` (optional): Previous clarification from the user if the question needed refinement
|
||||
- `originalQuestion` (optional): The original question if this is a follow-up
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"name": "search",
|
||||
"arguments": {
|
||||
"question": "What are the latest developments in AI?"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required:
|
||||
- `EXA_API_KEY`: Your Exa API key
|
||||
- `OPENROUTER_API_KEY`: Your OpenRouter API key
|
||||
|
||||
Optional:
|
||||
- `PORT`: Port for REST server (default: 3000)
|
||||
- `HOST`: Host for REST server (default: 0.0.0.0)
|
||||
- `MAINTENANCE_MODE`: Set to "true" to enable maintenance mode
|
||||
11
mcpServer.js
Executable file
11
mcpServer.js
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/home/seb/.nvm/versions/node/v22.15.1/bin/node
|
||||
import dotenv from 'dotenv';
|
||||
import { startMCPServer } from './src/mcpServer.js';
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
|
||||
startMCPServer().catch((error) => {
|
||||
console.error('Failed to start MCP server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
336
package-lock.json
generated
336
package-lock.json
generated
@@ -9,9 +9,65 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"@openrouter/sdk": "^0.11.2",
|
||||
"chalk": "^5.6.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"exa-js": "^2.11.0",
|
||||
"express": "^5.2.1"
|
||||
"express": "^5.2.1",
|
||||
"tiktoken": "^1.0.22"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz",
|
||||
"integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"cross-spawn": "^7.0.5",
|
||||
"eventsource": "^3.0.2",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"hono": "^4.11.4",
|
||||
"jose": "^6.1.3",
|
||||
"json-schema-typed": "^8.0.2",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.25 || ^4.0",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cfworker/json-schema": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@openrouter/sdk": {
|
||||
@@ -37,6 +93,39 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -99,6 +188,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
@@ -139,6 +240,23 @@
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
|
||||
@@ -148,6 +266,20 @@
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -175,9 +307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
||||
"version": "17.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz",
|
||||
"integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -260,6 +392,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventsource-parser": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
|
||||
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exa-js": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/exa-js/-/exa-js-2.11.0.tgz",
|
||||
@@ -273,6 +426,18 @@
|
||||
"zod-to-json-schema": "^3.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exa-js/node_modules/dotenv": {
|
||||
"version": "16.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
@@ -316,6 +481,46 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
@@ -437,6 +642,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.10",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz",
|
||||
"integrity": "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -479,6 +693,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -494,6 +717,33 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-typed": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -584,6 +834,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -647,6 +906,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
@@ -657,6 +925,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -709,6 +986,15 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@@ -782,6 +1068,27 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
@@ -863,6 +1170,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiktoken": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.22.tgz",
|
||||
"integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -926,6 +1239,21 @@
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
||||
@@ -5,15 +5,20 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "node restSearch.js",
|
||||
"search": "node searchCLI.js",
|
||||
"mcp": "node mcpServer.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"@openrouter/sdk": "^0.11.2",
|
||||
"chalk": "^5.6.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"exa-js": "^2.11.0",
|
||||
"express": "^5.2.1"
|
||||
"express": "^5.2.1",
|
||||
"tiktoken": "^1.0.22"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +289,90 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.suggested-searches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.suggested-search-btn {
|
||||
padding: 8px 16px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.suggested-search-btn:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
/* Cost Breakdown Styles */
|
||||
.cost-breakdown-details {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.cost-breakdown-summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
padding: 12px 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #667eea;
|
||||
transition: background 0.2s;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.cost-breakdown-summary:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.cost-breakdown-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cost-breakdown-content {
|
||||
padding: 15px;
|
||||
margin-top: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.cost-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cost-table th,
|
||||
.cost-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.cost-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.cost-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cost-table tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
@@ -454,16 +538,17 @@
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔍 Search Agent</h1>
|
||||
<h1>🔍 Such-Agent</h1>
|
||||
</div>
|
||||
|
||||
<div class="search-section">
|
||||
<!-- Example Queries - Moved to top -->
|
||||
<div class="example-queries" style="margin-top: 0; padding-top: 0; border-top: none; margin-bottom: 20px;">
|
||||
<h3>Beispiel Anfragen:</h3>
|
||||
<button class="example-btn" onclick="setExample('Wie ist die Lage im Iran?')">Lage im Iran</button>
|
||||
<button class="example-btn" onclick="setExample('Wie ist die aktuelle Nachrichten Lage im Iran?')">Lage im Iran</button>
|
||||
<button class="example-btn" onclick="setExample('Welche KI Modelle wurden in den letzten Tagen veröffentlicht?')">Neue KI-Modelle</button>
|
||||
<button class="example-btn" onclick="setExample('Wie ist das Wetter in Dresden?')">Wetter in Dresden</button>
|
||||
<button class="example-btn" onclick="setExample('Was ist neu in React 19.2?')">React 19.2</button>
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
@@ -473,26 +558,56 @@
|
||||
id="questionInput"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button class="search-btn" id="searchBtn">Suche</button>
|
||||
<button class="search-btn" id="searchBtn">Suchen</button>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Searching and analyzing results...</p>
|
||||
<p>Suche und analysiere Ergebnisse...</p>
|
||||
</div>
|
||||
|
||||
<div class="error" id="error"></div>
|
||||
|
||||
<div class="results" id="results">
|
||||
<div class="result-section">
|
||||
<h2>💡 Vorgeschlagene Folgesuchen</h2>
|
||||
<div class="suggested-searches" id="suggestedSearches"></div>
|
||||
</div>
|
||||
|
||||
<div class="result-section">
|
||||
<h2>📝 Answer</h2>
|
||||
<h2>📝 Antwort</h2>
|
||||
<div class="answer" id="answer"></div>
|
||||
</div>
|
||||
|
||||
<div class="result-section">
|
||||
<h2>🔗 Most Relevant Sources</h2>
|
||||
<h2>🔗 Relevanteste Quellen</h2>
|
||||
<ul class="sources" id="sources"></ul>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Cost Breakdown Section -->
|
||||
<div class="result-section">
|
||||
<details class="cost-breakdown-details" id="costBreakdownDetails">
|
||||
<summary class="cost-breakdown-summary">💰 Kostenaufschlüsselung</summary>
|
||||
<div class="cost-breakdown-content">
|
||||
<table class="cost-table" id="costTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Typ</th>
|
||||
<th>Modell</th>
|
||||
<th>Eingabe</th>
|
||||
<th>Ausgabe</th>
|
||||
<th>Kosten (USD)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="costTableBody">
|
||||
<!-- Cost rows will be inserted here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Panel -->
|
||||
@@ -501,9 +616,9 @@
|
||||
<div style="display: flex; align-items: center; gap: 15px;">
|
||||
<div class="connection-status">
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<span id="statusText">Connecting...</span>
|
||||
<span id="statusText">Verbinde...</span>
|
||||
</div>
|
||||
<button class="clear-logs-btn" id="clearLogsBtn">Clear</button>
|
||||
<button class="clear-logs-btn" id="clearLogsBtn">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-container" id="logContainer">
|
||||
@@ -522,6 +637,7 @@
|
||||
const results = document.getElementById('results');
|
||||
const answerEl = document.getElementById('answer');
|
||||
const sourcesEl = document.getElementById('sources');
|
||||
const suggestedSearchesEl = document.getElementById('suggestedSearches');
|
||||
const errorEl = document.getElementById('error');
|
||||
const logContainer = document.getElementById('logContainer');
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
@@ -531,14 +647,18 @@
|
||||
// SSE connection
|
||||
let eventSource = null;
|
||||
let logEntries = [];
|
||||
|
||||
// Clarification state
|
||||
let isAwaitingClarification = false;
|
||||
let clarificationData = null;
|
||||
|
||||
function connectToSSE() {
|
||||
eventSource = new EventSource('/stream');
|
||||
|
||||
eventSource.onopen = function() {
|
||||
statusDot.className = 'status-dot connected';
|
||||
statusText.textContent = 'Connected';
|
||||
addLogEntry('Connected to log stream', 'info');
|
||||
statusText.textContent = 'Verbunden';
|
||||
addLogEntry('Mit Log-Stream verbunden', 'info');
|
||||
};
|
||||
|
||||
eventSource.onmessage = function(event) {
|
||||
@@ -572,7 +692,7 @@
|
||||
|
||||
eventSource.onerror = function(err) {
|
||||
statusDot.className = 'status-dot disconnected';
|
||||
statusText.textContent = 'Disconnected';
|
||||
statusText.textContent = 'Getrennt';
|
||||
console.error('SSE Error:', err);
|
||||
|
||||
// Attempt to reconnect after 3 seconds
|
||||
@@ -646,7 +766,7 @@
|
||||
async function performSearch() {
|
||||
const question = questionInput.value.trim();
|
||||
if (!question) {
|
||||
showError('Please enter a question');
|
||||
showError('Bitte geben Sie eine Frage ein');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -679,10 +799,111 @@
|
||||
searchBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function performSearchWithClarification(clarificationText) {
|
||||
if (!clarificationData || !clarificationData.originalQuestion) {
|
||||
showError('Klärungsdaten nicht verfügbar');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading
|
||||
hideError();
|
||||
hideResults();
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
const response = await fetch('/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
question: clarificationData.originalQuestion + ' ' + clarificationText,
|
||||
previousClarification: clarificationText,
|
||||
originalQuestion: clarificationData.originalQuestion
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Search failed');
|
||||
}
|
||||
|
||||
displayResults(data);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
// Check if clarification is needed
|
||||
if (data.clarificationNeeded) {
|
||||
isAwaitingClarification = true;
|
||||
clarificationData = {
|
||||
originalQuestion: data.originalQuestion
|
||||
};
|
||||
|
||||
// Show clarification prompt in the answer area
|
||||
answerEl.innerHTML = `
|
||||
<div style="background: #fff3cd; padding: 20px; border-radius: 8px; border-left: 4px solid #ffc107;">
|
||||
<p style="margin-bottom: 15px; font-weight: 600;">${data.fullAnswerHTMLSnippet}</p>
|
||||
<input
|
||||
type="text"
|
||||
id="clarificationInput"
|
||||
class="search-input"
|
||||
style="margin-bottom: 10px;"
|
||||
>
|
||||
<button
|
||||
class="search-btn"
|
||||
id="clarificationSubmitBtn"
|
||||
style="padding: 10px 20px; font-size: 1rem;"
|
||||
>
|
||||
Klärung einreichen
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Clear sources since we're waiting for clarification
|
||||
sourcesEl.innerHTML = '<li>Warte auf Klärung...</li>';
|
||||
|
||||
// Add event listener for clarification submission
|
||||
setTimeout(() => {
|
||||
const clarificationInput = document.getElementById('clarificationInput');
|
||||
const clarificationSubmitBtn = document.getElementById('clarificationSubmitBtn');
|
||||
|
||||
if (clarificationInput && clarificationSubmitBtn) {
|
||||
clarificationInput.focus();
|
||||
|
||||
const handleClarification = () => {
|
||||
const clarificationText = clarificationInput.value.trim();
|
||||
if (clarificationText) {
|
||||
// Search again with the clarification
|
||||
performSearchWithClarification(clarificationText);
|
||||
}
|
||||
};
|
||||
|
||||
clarificationSubmitBtn.addEventListener('click', handleClarification);
|
||||
clarificationInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleClarification();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
showResults();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset clarification state for normal results
|
||||
isAwaitingClarification = false;
|
||||
clarificationData = null;
|
||||
|
||||
// Display answer as HTML
|
||||
answerEl.innerHTML = data.fullAnswerHTMLSnippet || '<p>No answer generated</p>';
|
||||
answerEl.innerHTML = data.fullAnswerHTMLSnippet || '<p>Keine Antwort generiert</p>';
|
||||
|
||||
// Display sources
|
||||
sourcesEl.innerHTML = '';
|
||||
@@ -699,10 +920,49 @@
|
||||
});
|
||||
} else {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = 'No sources available';
|
||||
li.textContent = 'Keine Quellen verfügbar';
|
||||
sourcesEl.appendChild(li);
|
||||
}
|
||||
|
||||
// Display suggested searches
|
||||
suggestedSearchesEl.innerHTML = '';
|
||||
if (data.suggestedSearches && data.suggestedSearches.length > 0) {
|
||||
data.suggestedSearches.forEach(search => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'suggested-search-btn';
|
||||
btn.textContent = search;
|
||||
btn.addEventListener('click', () => {
|
||||
questionInput.value = search;
|
||||
performSearch();
|
||||
});
|
||||
suggestedSearchesEl.appendChild(btn);
|
||||
});
|
||||
} else {
|
||||
suggestedSearchesEl.innerHTML = '<p style="color: #999;">Keine vorgeschlagenen Suchen verfügbar</p>';
|
||||
}
|
||||
|
||||
// Display cost breakdown
|
||||
const costTableBody = document.getElementById('costTableBody');
|
||||
costTableBody.innerHTML = '';
|
||||
if (data.costBreakdown && data.costBreakdown.length > 0) {
|
||||
data.costBreakdown.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${item.index}</td>
|
||||
<td>${item.type}</td>
|
||||
<td>${item.model || '-'}</td>
|
||||
<td>${item.prompt_tokens || '-'}</td>
|
||||
<td>${item.completion_tokens || '-'}</td>
|
||||
<td>${item.cost}</td>
|
||||
`;
|
||||
costTableBody.appendChild(tr);
|
||||
});
|
||||
} else {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td colspan="6">Keine Kostendaten verfügbar</td>';
|
||||
costTableBody.appendChild(tr);
|
||||
}
|
||||
|
||||
showResults();
|
||||
}
|
||||
|
||||
|
||||
76
searchCLI.js
Normal file
76
searchCLI.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { createClients } from './src/clients.js';
|
||||
import { getConfig, validateConfig } from './src/config/env.js';
|
||||
import { createSearchService } from './src/services/searchService.js';
|
||||
import { renderHTML } from './src/utils/htmlConsoleRenderer.js';
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
|
||||
function printUsage() {
|
||||
console.log('Usage: node searchCLI.js <question>');
|
||||
console.log('');
|
||||
console.log('Example:');
|
||||
console.log(' node searchCLI.js "What are the latest developments in AI?"');
|
||||
}
|
||||
|
||||
function createSimpleBroadcast() {
|
||||
return (message, type = 'info', data = null) => {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const prefix = type === 'error' ? '❌' : type === 'warning' ? '⚠️' : type === 'success' ? '✅' : 'ℹ️';
|
||||
console.log(`[${timestamp}] ${prefix} ${message}`);
|
||||
};
|
||||
}
|
||||
|
||||
async function runCLI() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||
printUsage();
|
||||
process.exit(args.includes('--help') || args.includes('-h') ? 0 : 1);
|
||||
}
|
||||
|
||||
const question = args.join(' ');
|
||||
|
||||
try {
|
||||
const config = getConfig();
|
||||
validateConfig(config);
|
||||
|
||||
const broadcast = createSimpleBroadcast();
|
||||
const clients = createClients(config);
|
||||
const searchService = createSearchService({
|
||||
...clients,
|
||||
broadcast,
|
||||
});
|
||||
|
||||
broadcast(`Starting search for: "${question}"`, 'info');
|
||||
|
||||
const result = await searchService.search(question);
|
||||
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log('FINAL ANSWER:');
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log(renderHTML(result.fullAnswerHTMLSnippet) || 'No answer generated');
|
||||
console.log('');
|
||||
|
||||
if (result.mostRelevantSources && result.mostRelevantSources.length > 0) {
|
||||
console.log('SOURCES:');
|
||||
result.mostRelevantSources.forEach((source, index) => {
|
||||
console.log(` ${index + 1}. ${source}`);
|
||||
});
|
||||
}
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('');
|
||||
console.error('❌ Error:', error.message);
|
||||
if (error.details) {
|
||||
console.error(' Details:', error.details);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runCLI();
|
||||
@@ -10,6 +10,7 @@ export function getConfig() {
|
||||
host: process.env.HOST || '0.0.0.0',
|
||||
exaApiKey: process.env.EXA_API_KEY,
|
||||
openRouterApiKey: process.env.OPENROUTER_API_KEY,
|
||||
maintenanceMode: process.env.MAINTENANCE_MODE === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
55
src/maintenanceApp.js
Normal file
55
src/maintenanceApp.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import express from 'express';
|
||||
|
||||
export function createMaintenanceApp() {
|
||||
const app = express();
|
||||
|
||||
app.get('/{*path}', (req, res) => {
|
||||
res.status(503).send(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Under Construction</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">🚧</div>
|
||||
<h1>Under Construction</h1>
|
||||
<p>Please return later.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
124
src/mcpServer.js
Normal file
124
src/mcpServer.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { createClients } from './clients.js';
|
||||
import { getConfig, validateConfig } from './config/env.js';
|
||||
import { createSearchService } from './services/searchService.js';
|
||||
|
||||
function createSimpleBroadcast() {
|
||||
return (message, type = 'info', data = null) => {
|
||||
// Log to stderr so it doesn't interfere with MCP protocol on stdout
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const prefix = type === 'error' ? '❌' : type === 'warning' ? '⚠️' : type === 'success' ? '✅' : 'ℹ️';
|
||||
console.error(`[${timestamp}] ${prefix} ${message}`);
|
||||
};
|
||||
}
|
||||
|
||||
export async function startMCPServer() {
|
||||
const config = getConfig();
|
||||
validateConfig(config);
|
||||
|
||||
const clients = createClients(config);
|
||||
const broadcast = createSimpleBroadcast();
|
||||
const searchService = createSearchService({
|
||||
...clients,
|
||||
broadcast,
|
||||
});
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'search-agent',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Handler for listing available tools
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Deep Web Research',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: {
|
||||
type: 'string',
|
||||
description: 'The question or topic to search for',
|
||||
},
|
||||
previousClarification: {
|
||||
type: 'string',
|
||||
description: 'Optional: Previous clarification from the user if the question needed refinement',
|
||||
},
|
||||
originalQuestion: {
|
||||
type: 'string',
|
||||
description: 'Optional: The original question if this is a follow-up',
|
||||
},
|
||||
},
|
||||
required: ['question'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// Handler for calling tools
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
if (name !== 'search') {
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
const { question, previousClarification, originalQuestion } = args;
|
||||
|
||||
if (!question) {
|
||||
throw new Error('Missing required parameter: question');
|
||||
}
|
||||
|
||||
try {
|
||||
broadcast(`MCP: Starting search for: "${question}"`, 'info');
|
||||
|
||||
const result = await searchService.search(question, previousClarification, originalQuestion);
|
||||
|
||||
broadcast('MCP: Search completed successfully', 'success');
|
||||
|
||||
// Return the result in a structured format
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result.fullAnswerHTMLSnippet || 'No answer generated',
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
broadcast(`MCP: Error: ${error.message}`, 'error');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error: ${error.message}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Start the server with stdio transport
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
console.error('🔌 MCP Search Agent server running on stdio');
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export function createSearchRouter(searchService, broadcast) {
|
||||
const router = Router();
|
||||
|
||||
router.post('/', async (request, response) => {
|
||||
const { question } = request.body;
|
||||
const { question, previousClarification, originalQuestion } = request.body;
|
||||
|
||||
if (!question) {
|
||||
response.status(400).json({
|
||||
@@ -16,7 +16,7 @@ export function createSearchRouter(searchService, broadcast) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await searchService.search(question);
|
||||
const result = await searchService.search(question, previousClarification, originalQuestion);
|
||||
response.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof SearchServiceError) {
|
||||
|
||||
@@ -1,37 +1,50 @@
|
||||
import { createApp } from './app.js';
|
||||
import { createMaintenanceApp } from './maintenanceApp.js';
|
||||
import { createClients } from './clients.js';
|
||||
import { getConfig, validateConfig } from './config/env.js';
|
||||
import { createSseHub } from './lib/sseHub.js';
|
||||
import { createSearchService } from './services/searchService.js';
|
||||
|
||||
function logStartup(host, port) {
|
||||
console.log(`REST Search Service running at http://${host}:${port}`);
|
||||
console.log(`Web UI: http://localhost:${port}`);
|
||||
console.log('API Documentation:');
|
||||
console.log(' POST /search - Search for a query and return summarized results');
|
||||
console.log(' GET /health - Health check endpoint');
|
||||
console.log(' GET /stream - SSE endpoint for streaming log messages');
|
||||
console.log('\nExample:');
|
||||
console.log(` curl -X POST http://${host}:${port}/search \\`);
|
||||
console.log(' -H "Content-Type: application/json" \\');
|
||||
console.log(' -d \'{"question": "What are the latest developments in AI?"}\'');
|
||||
console.log('\nSSE Log Stream:');
|
||||
console.log(` Open http://localhost:${port}/stream in a browser or use EventSource in JavaScript`);
|
||||
function logStartup(host, port, isMaintenance) {
|
||||
if (isMaintenance) {
|
||||
console.log(`🚧 Maintenance Mode running at http://${host}:${port}`);
|
||||
console.log('All requests will receive "Under Construction" page');
|
||||
} else {
|
||||
console.log(`REST Search Service running at http://${host}:${port}`);
|
||||
console.log(`Web UI: http://localhost:${port}`);
|
||||
console.log('API Documentation:');
|
||||
console.log(' POST /search - Search for a query and return summarized results');
|
||||
console.log(' GET /health - Health check endpoint');
|
||||
console.log(' GET /stream - SSE endpoint for streaming log messages');
|
||||
console.log('\nExample:');
|
||||
console.log(` curl -X POST http://${host}:${port}/search \\`);
|
||||
console.log(' -H "Content-Type: application/json" \\');
|
||||
console.log(' -d \'{"question": "What are the latest developments in AI?"}\'');
|
||||
console.log('\nSSE Log Stream:');
|
||||
console.log(` Open http://localhost:${port}/stream in a browser or use EventSource in JavaScript`);
|
||||
}
|
||||
}
|
||||
|
||||
export function startServer() {
|
||||
const config = getConfig();
|
||||
validateConfig(config);
|
||||
|
||||
const clients = createClients(config);
|
||||
const sseHub = createSseHub();
|
||||
const searchService = createSearchService({
|
||||
...clients,
|
||||
broadcast: sseHub.broadcast,
|
||||
});
|
||||
const app = createApp({ searchService, sseHub });
|
||||
const isMaintenance = config.maintenanceMode;
|
||||
|
||||
let app;
|
||||
if (isMaintenance) {
|
||||
app = createMaintenanceApp();
|
||||
} else {
|
||||
const clients = createClients(config);
|
||||
const sseHub = createSseHub();
|
||||
const searchService = createSearchService({
|
||||
...clients,
|
||||
broadcast: sseHub.broadcast,
|
||||
});
|
||||
app = createApp({ searchService, sseHub });
|
||||
}
|
||||
|
||||
app.listen(config.port, config.host, () => {
|
||||
logStartup(config.host, config.port);
|
||||
logStartup(config.host, config.port, isMaintenance);
|
||||
});
|
||||
}
|
||||
|
||||
74
src/services/extractionLogger.js
Normal file
74
src/services/extractionLogger.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const LOG_DIR = path.join(__dirname, '../../logs');
|
||||
const LOG_FILE = path.join(LOG_DIR, 'extraction.log');
|
||||
|
||||
/**
|
||||
* Ensures the log directory exists
|
||||
*/
|
||||
async function ensureLogDirectory() {
|
||||
try {
|
||||
await fs.mkdir(LOG_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to create log directory:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp for logging
|
||||
*/
|
||||
function formatTimestamp() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an extraction event with input and output
|
||||
* @param {Object} input - The input data (search result from Exa)
|
||||
* @param {string} output - The extracted text content
|
||||
*/
|
||||
export async function logExtraction(input, output) {
|
||||
try {
|
||||
await ensureLogDirectory();
|
||||
|
||||
const timestamp = formatTimestamp();
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
input: {
|
||||
resultCount: input?.results?.length || 0,
|
||||
results: input?.results?.map((r) => ({
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
textPreview: r.text?.substring(0, 200) + (r.text?.length > 200 ? '...' : ''),
|
||||
summaryPreview: r.summary?.substring(0, 200) + (r.summary?.length > 200 ? '...' : ''),
|
||||
highlightsCount: r.highlights?.length || 0,
|
||||
publishedDate: r.publishedDate,
|
||||
author: r.author,
|
||||
})),
|
||||
},
|
||||
output: {
|
||||
length: output.length,
|
||||
preview: output.substring(0, 500) + (output.length > 500 ? '...' : ''),
|
||||
},
|
||||
};
|
||||
|
||||
const logLine = `${timestamp} | Input: ${JSON.stringify(logEntry.input)} | Output: [${logEntry.output.length} chars] ${logEntry.output.preview}\n`;
|
||||
|
||||
await fs.appendFile(LOG_FILE, logLine, 'utf8');
|
||||
} catch (error) {
|
||||
console.error('Failed to log extraction:', error);
|
||||
// Don't throw - logging should not break the main functionality
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the extraction log file
|
||||
*/
|
||||
export function getLogFilePath() {
|
||||
return LOG_FILE;
|
||||
}
|
||||
58
src/services/openRouterLogger.js
Normal file
58
src/services/openRouterLogger.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const LOG_DIR = path.join(__dirname, '../../logs');
|
||||
|
||||
/**
|
||||
* Ensures the log directory exists
|
||||
*/
|
||||
async function ensureLogDirectory() {
|
||||
try {
|
||||
await fs.mkdir(LOG_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to create log directory:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp for logging
|
||||
*/
|
||||
function formatTimestamp() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an OpenRouter API call with params and response
|
||||
* @param {string} operation - The operation name (e.g., 'summarizeSources', 'summarizeFinalAnswer')
|
||||
* @param {Object} params - The request parameters sent to OpenRouter
|
||||
* @param {Object} response - The response from OpenRouter
|
||||
* @param {Error} [error] - Optional error if the call failed
|
||||
*/
|
||||
export async function logOpenRouterCall(operation, text, params, response, error = null) {
|
||||
try {
|
||||
await ensureLogDirectory();
|
||||
|
||||
const timestamp = formatTimestamp();
|
||||
const logFileName = `openrouter-${timestamp.replace(/[:.]/g, '-')}.log`;
|
||||
const logFilePath = path.join(LOG_DIR, logFileName);
|
||||
|
||||
const logContent = `${timestamp} | ${operation}\n\nParams:\n${JSON.stringify(params, null, 2)}\n\nResponse:\n${JSON.stringify(response, null, 2)}${error ? `\n\nError: ${error.message}` : ''}\n`;
|
||||
|
||||
await fs.writeFile(logFilePath, text + logContent, 'utf8');
|
||||
} catch (logError) {
|
||||
console.error('Failed to log OpenRouter call:', logError);
|
||||
// Don't throw - logging should not break the main functionality
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the OpenRouter log directory
|
||||
*/
|
||||
export function getOpenRouterLogDirPath() {
|
||||
return LOG_DIR;
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import { logOpenRouterCall } from './openRouterLogger.js';
|
||||
|
||||
function parseResponse(response) {
|
||||
return JSON.parse(response.choices[0].message.content);
|
||||
if(!response?.usage?.cost) console.log(response);
|
||||
console.log('OpenRouter API call cost:', response?.usage);
|
||||
return {
|
||||
cost: response?.usage?.cost,
|
||||
prompt_tokens: response?.usage?.prompt_tokens,
|
||||
completion_tokens: response?.usage?.completion_tokens,
|
||||
data: JSON.parse(response.choices[0].message.content)
|
||||
};
|
||||
}
|
||||
|
||||
export async function summarizeSources({ openrouter, text, question }) {
|
||||
@@ -16,9 +25,9 @@ export async function summarizeSources({ openrouter, text, question }) {
|
||||
{ role: 'user', content: text },
|
||||
],
|
||||
reasoning: { effort: 'low' },
|
||||
responseFormat: {
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
jsonSchema: {
|
||||
json_schema: {
|
||||
name: 'search_summaries',
|
||||
strict: true,
|
||||
schema: {
|
||||
@@ -45,39 +54,47 @@ export async function summarizeSources({ openrouter, text, question }) {
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const response = await openrouter.chat.send({ chatRequest: params });
|
||||
// Using direct fetch API instead of OpenRouter SDK
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
const fetchResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const response = await fetchResponse.json();
|
||||
await logOpenRouterCall('summarizeSources', text, params, response);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function summarizeFinalAnswer({ openrouter, text, question }) {
|
||||
export async function summarizeDetail({ text, url, question }) {
|
||||
const prompt = `
|
||||
You are a search result analyst. Today is the date of ${new Date().toLocaleDateString()}.
|
||||
Based on the following search results for the query "${question}",
|
||||
Summarize the search results to answer the original query. Use Emoji and HTML. Tags allowed: <b>, <i>, <u>, <ul>, <li>, <span style="color:...">, <p> <div> <hr/>
|
||||
Also provide the most relevant sources.
|
||||
You are a search result analyst.
|
||||
The original query was "${question}".
|
||||
A detailed search on the following source "${url}" has returned a result,
|
||||
filter this result to extract the most relevant information to answer the original query.
|
||||
`;
|
||||
|
||||
const params = {
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
model: 'openai/gpt-oss-120b:nitro',
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{ role: 'user', content: text },
|
||||
],
|
||||
reasoning: { effort: 'none' },
|
||||
responseFormat: {
|
||||
reasoning: { effort: 'low' },
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
jsonSchema: {
|
||||
name: 'response',
|
||||
json_schema: {
|
||||
name: 'summary',
|
||||
strict: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['fullAnswerHTMLSnippet', 'mostRelevantSources'],
|
||||
required: ['summary'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
fullAnswerHTMLSnippet: { type: 'string' },
|
||||
mostRelevantSources: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
summary: { type: 'string', description: 'Filtered summary of the relevant information' },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -85,6 +102,226 @@ export async function summarizeFinalAnswer({ openrouter, text, question }) {
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const response = await openrouter.chat.send({ chatRequest: params });
|
||||
// Using direct fetch API instead of OpenRouter SDK
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
const fetchResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const response = await fetchResponse.json();
|
||||
await logOpenRouterCall('summarizeDetail', text, params, response);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function rephraseQuestion({ question, previousClarification, originalQuestion }) {
|
||||
|
||||
if(previousClarification) {
|
||||
const prompt = `
|
||||
You are a search query expert. You are given a question and you return
|
||||
a search query for a web search engine that would return the best results to answer the question.
|
||||
Do NOT restrict the query using site: operator.
|
||||
Also give a list of 2 supplementary search queries to deepen the search.
|
||||
Today is the date of ${new Date().toLocaleDateString()}.
|
||||
The user has provided this clarification "${previousClarification}" to the original question: ` + originalQuestion;
|
||||
|
||||
const params = {
|
||||
model: 'z-ai/glm-4.7:nitro',
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{ role: 'user', content: question },
|
||||
],
|
||||
reasoning: { effort: 'none' },
|
||||
stream: false,
|
||||
response_format: {
|
||||
type: 'json_schema', json_schema: {
|
||||
name: 'queries', strict: true, schema: {
|
||||
required: [ 'query', 'supplementaryQueries'], type: 'object', additionalProperties: false, properties: {
|
||||
query: { type: 'string' },
|
||||
supplementaryQueries: { type: 'array', items: { type: 'string' } },
|
||||
} } } }
|
||||
};
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
const fetchResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const response = await fetchResponse.json();
|
||||
await logOpenRouterCall('rephraseQuestion', question,params, response);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
|
||||
const prompt = `
|
||||
You are a search query expert. You are given a question and you return
|
||||
a search query for a web search engine that would return the best results to answer the question.
|
||||
Do NOT restrict the query using site: operator.
|
||||
Also give a list of 2 supplementary search queries to deepen the search.
|
||||
Today is the date of ${new Date().toLocaleDateString()}.
|
||||
The user cannot ask questions that a web search engine cannot answer.
|
||||
Like "Who are you".
|
||||
If the question is ambiguous or unsuited for a web search, you can ask for clarification.
|
||||
${previousClarification ? `The user has provided this clarification to the original question: "${previousClarification}". Use this clarification to refine the search query.` : ''}
|
||||
`;
|
||||
|
||||
const params = {
|
||||
model: 'z-ai/glm-4.7:nitro',
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{ role: 'user', content: question },
|
||||
],
|
||||
reasoning: { effort: 'none' },
|
||||
stream: false,
|
||||
response_format: {
|
||||
type: 'json_schema', json_schema: {
|
||||
name: 'queries', strict: true, schema: {
|
||||
required: ['needsClarification', 'clarification', 'query', 'supplementaryQueries'], type: 'object', additionalProperties: false, properties: {
|
||||
needsClarification: { type: 'boolean', description: 'Indicates if the question is ambiguous and needs clarification' },
|
||||
clarification: { type: 'string', description: 'If needsClarification is true, this field contains the clarification question to ask the user. Otherwise, it is an empty string.' },
|
||||
query: { type: 'string' },
|
||||
supplementaryQueries: { type: 'array', items: { type: 'string' } },
|
||||
} } } }
|
||||
};
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
const fetchResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const response = await fetchResponse.json();
|
||||
await logOpenRouterCall('rephraseQuestion', question,params, response);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
export async function summarizeFinalAnswer({ openrouter, text, question }) {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
// Step 1: Get summary, sources, and suggested searches
|
||||
const summaryPrompt = `
|
||||
You are a search result analyst. Today is the date of ${new Date().toLocaleDateString()}.
|
||||
Based on the following search results for the query "${question}",
|
||||
Summarize the search results to answer the original query in a detailed manner.
|
||||
Also provide the most relevant sources. Answer in the language of the question. You may suggest 2 followup searches to the user.
|
||||
`;
|
||||
|
||||
const summaryParams = {
|
||||
model: 'openai/gpt-oss-120b:nitro',
|
||||
messages: [
|
||||
{ role: 'system', content: summaryPrompt },
|
||||
{ role: 'user', content: text },
|
||||
],
|
||||
reasoning: { effort: 'low' },
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'summary',
|
||||
strict: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['summary', 'mostRelevantSources', 'suggestedSearches'],
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
mostRelevantSources: { type: 'array', items: { type: 'string' } },
|
||||
suggestedSearches: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const summaryFetchResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(summaryParams),
|
||||
});
|
||||
const summaryResponse = await summaryFetchResponse.json();
|
||||
await logOpenRouterCall('summarizeFinalAnswer-step1', text, summaryParams, summaryResponse);
|
||||
const summaryData = parseResponse(summaryResponse);
|
||||
|
||||
// Step 2: Enrich the summary with HTML tags and emojis
|
||||
const enrichmentPrompt = `
|
||||
You are a content formatter. Take the following summary and enrich it with emojis and HTML tags.
|
||||
Allowed tags: <b>, <i>, <u>, <pre>, <ul>, <li>, <span style="color:...">, <p>, <div>, <hr/>
|
||||
Make it visually appealing and easy to read.
|
||||
`;
|
||||
|
||||
const enrichmentParams = {
|
||||
model: 'qwen/qwen3-235b-a22b-2507:nitro',
|
||||
messages: [
|
||||
{ role: 'system', content: enrichmentPrompt },
|
||||
{ role: 'user', content: summaryData.data.summary },
|
||||
],
|
||||
reasoning: { effort: 'low' },
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'enrichedSummary',
|
||||
strict: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['fullAnswerHTMLSnippet'],
|
||||
properties: {
|
||||
fullAnswerHTMLSnippet: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const enrichmentFetchResponse = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(enrichmentParams),
|
||||
});
|
||||
const enrichmentResponse = await enrichmentFetchResponse.json();
|
||||
await logOpenRouterCall('summarizeFinalAnswer-step2', summaryData.data.summary, enrichmentParams, enrichmentResponse);
|
||||
const enrichmentData = parseResponse(enrichmentResponse);
|
||||
|
||||
// Combine results with separate cost breakdowns
|
||||
return {
|
||||
totalCost: summaryData.cost + enrichmentData.cost,
|
||||
totalPromptTokens: summaryData.prompt_tokens + enrichmentData.prompt_tokens,
|
||||
totalCompletionTokens: summaryData.completion_tokens + enrichmentData.completion_tokens,
|
||||
steps: [
|
||||
{
|
||||
name: 'summary',
|
||||
model: 'openai/gpt-oss-120b:nitro',
|
||||
cost: summaryData.cost,
|
||||
promptTokens: summaryData.prompt_tokens,
|
||||
completionTokens: summaryData.completion_tokens,
|
||||
},
|
||||
{
|
||||
name: 'enrichment',
|
||||
model: 'qwen/qwen3-235b-a22b-2507:nitro',
|
||||
cost: enrichmentData.cost,
|
||||
promptTokens: enrichmentData.prompt_tokens,
|
||||
completionTokens: enrichmentData.completion_tokens,
|
||||
},
|
||||
],
|
||||
data: {
|
||||
fullAnswerHTMLSnippet: enrichmentData.data.fullAnswerHTMLSnippet,
|
||||
mostRelevantSources: summaryData.data.mostRelevantSources,
|
||||
suggestedSearches: summaryData.data.suggestedSearches,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ function wrapText(text, maxLineLength = 68) {
|
||||
return output;
|
||||
}
|
||||
|
||||
export function formatSummarySources(sources) {
|
||||
export function formatSummarySources(sources,supplementaryResults=[]) {
|
||||
let output = `\n${'='.repeat(70)}\n`;
|
||||
output += ` ${'SUMMARY'.padStart(35).padEnd(69)}\n`;
|
||||
output += `${'='.repeat(70)}\n`;
|
||||
@@ -38,6 +38,13 @@ export function formatSummarySources(sources) {
|
||||
}
|
||||
});
|
||||
|
||||
if (supplementaryResults.length > 0) {
|
||||
supplementaryResults.forEach((result) => {
|
||||
output += `\n${'-'.repeat(70)}\n`;
|
||||
output += JSON.stringify(result, null, 2);
|
||||
});
|
||||
}
|
||||
|
||||
output += `\n${'='.repeat(70)}\n`;
|
||||
output += `Total sources: ${sources.length}\n`;
|
||||
output += `${'='.repeat(70)}\n`;
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { extractContent } from './extractContent.js';
|
||||
import { summarizeFinalAnswer, summarizeSources } from './openRouterService.js';
|
||||
import { logExtraction } from './extractionLogger.js';
|
||||
import { summarizeFinalAnswer, summarizeSources, summarizeDetail, rephraseQuestion } from './openRouterService.js';
|
||||
import { formatSummarySources } from './searchFormatter.js';
|
||||
import { get_encoding } from 'tiktoken';
|
||||
|
||||
const encoding = get_encoding('cl100k_base');
|
||||
|
||||
function countTokens(text) {
|
||||
if (!text) return 0;
|
||||
const tokens = encoding.encode(text);
|
||||
return tokens.length;
|
||||
}
|
||||
|
||||
const EXA_SEARCH_OPTIONS = {
|
||||
numResults: 10,
|
||||
@@ -47,7 +57,23 @@ async function fetchDetailedContents({ exa, question, sources, broadcast }) {
|
||||
);
|
||||
|
||||
const content = await exa.getContents([source.url], EXA_CONTENT_OPTIONS(question));
|
||||
return { url: source.url, content };
|
||||
|
||||
console.log(content.results[0].highlights);
|
||||
return { url: source.url, content , cost: content.costDollars.total };
|
||||
|
||||
/*const summary = await summarizeDetail({text: content.results, url: source.url, question})
|
||||
|
||||
const cost2 = {
|
||||
type:'openrouter_detail_summary',
|
||||
amount: summary.cost,
|
||||
prompt_tokens: summary.prompt_tokens,
|
||||
completion_tokens: summary.completion_tokens,
|
||||
model: 'openai/gpt-oss-120b:nitro'
|
||||
};
|
||||
|
||||
const summaryIsLonger = JSON.stringify(summary.data).length > JSON.stringify(content.results).length;
|
||||
|
||||
return { url: source.url, content: summaryIsLonger ? content.results : source.summary, cost: content.costDollars.total, cost2 };*/
|
||||
} catch (error) {
|
||||
broadcast(`⚠️ Could not fetch content for ${source.url}: ${error.message}`, 'warning');
|
||||
return { url: source.url, content: null };
|
||||
@@ -71,19 +97,123 @@ function enrichSourcesWithDetails(sources, detailedContents) {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to build cost breakdown
|
||||
function buildCostBreakdown(cost) {
|
||||
// Print cost breakdown as a formatted table
|
||||
console.log('\n=== Cost Breakdown ===');
|
||||
console.table(
|
||||
cost.map((item, index) => ({
|
||||
'#': index + 1,
|
||||
Type: item.type,
|
||||
Model: item.model || '-',
|
||||
'Cost (USD)': `$${item.amount.toFixed(6)}`,
|
||||
})),
|
||||
);
|
||||
const totalCost = cost.reduce((sum, item) => sum + item.amount, 0);
|
||||
console.log(`Total: $${totalCost.toFixed(6)}\n`);
|
||||
|
||||
// Build cost breakdown table for API response
|
||||
const costBreakdown = cost.map((item, index) => ({
|
||||
index: index + 1,
|
||||
type: item.type,
|
||||
model: item.model || '-',
|
||||
prompt_tokens: item.prompt_tokens,
|
||||
completion_tokens: item.completion_tokens,
|
||||
cost: `$${item.amount.toFixed(6)}`,
|
||||
}));
|
||||
|
||||
// Add total to the breakdown
|
||||
costBreakdown.push({
|
||||
index: costBreakdown.length + 1,
|
||||
type: 'Total',
|
||||
cost: `$${totalCost.toFixed(6)}`,
|
||||
});
|
||||
|
||||
return costBreakdown;
|
||||
}
|
||||
|
||||
export function createSearchService({ exa, openrouter, broadcast }) {
|
||||
return {
|
||||
async search(question) {
|
||||
broadcast(`🔍 Search request: "${question}"`, 'info', { question });
|
||||
async search(question, previousClarification, originalQuestion) {
|
||||
let finalSummary;
|
||||
const cost=[];
|
||||
let rephrasedQuestion;
|
||||
|
||||
broadcast('Searching with Exa...', 'info');
|
||||
const result = await exa.search(question, EXA_SEARCH_OPTIONS);
|
||||
try {
|
||||
const rephraseResult = await rephraseQuestion({question, previousClarification, originalQuestion});
|
||||
rephrasedQuestion = rephraseResult.data;
|
||||
cost.push({
|
||||
type:'openrouter_rephrase',
|
||||
amount: rephraseResult.cost,
|
||||
prompt_tokens: rephraseResult.prompt_tokens,
|
||||
completion_tokens: rephraseResult.completion_tokens,
|
||||
model: 'z-ai/glm-4.7:nitro'
|
||||
});
|
||||
} catch (error) {
|
||||
throw new SearchServiceError('Failed to generate summary', 500, error);
|
||||
}
|
||||
if (rephrasedQuestion.needsClarification) {
|
||||
finalSummary = {
|
||||
fullAnswerHTMLSnippet: rephrasedQuestion.clarification,
|
||||
clarificationNeeded: true,
|
||||
originalQuestion: question,
|
||||
mostRelevantSources: [],
|
||||
};
|
||||
// Attach cost breakdown to the final summary
|
||||
finalSummary.costBreakdown = buildCostBreakdown(cost);
|
||||
|
||||
return finalSummary;
|
||||
|
||||
}
|
||||
|
||||
// Limit to first 2 supplementary queries
|
||||
const limitedQueries = rephrasedQuestion.supplementaryQueries.slice(0, 2);
|
||||
//limitedQueries.push(rephrasedQuestion.query);
|
||||
const supplementaryResults = [];
|
||||
for (const item of limitedQueries) {
|
||||
console.log('Supplementary Query:', item);
|
||||
const result = await exa.search(item, {
|
||||
numResults: 2,
|
||||
type: 'auto',
|
||||
contents: {
|
||||
highlights: {
|
||||
maxCharacters: 2000,
|
||||
},
|
||||
},
|
||||
});
|
||||
supplementaryResults.push({
|
||||
query: item,
|
||||
highlights: result.results.map(r => r.highlights),
|
||||
});
|
||||
const outputTokens = countTokens(JSON.stringify(result.results));
|
||||
cost.push({
|
||||
type: 'exa_search_complement',
|
||||
amount: result.costDollars.total,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: outputTokens
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
broadcast('Searching with Exa for '+rephrasedQuestion.query, 'info');
|
||||
const result = await exa.search(rephrasedQuestion.query, EXA_SEARCH_OPTIONS);
|
||||
broadcast(`✅ Found ${result.results.length} results`, 'success', {
|
||||
count: result.results.length,
|
||||
});
|
||||
|
||||
const outputTokens = countTokens(JSON.stringify(result.results));
|
||||
cost.push({
|
||||
type:'exa_search',
|
||||
amount: result.costDollars.total,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: outputTokens
|
||||
});
|
||||
|
||||
const extractedContent = extractContent(result);
|
||||
|
||||
// Log the extraction
|
||||
await logExtraction(result, extractedContent);
|
||||
|
||||
if (!extractedContent.trim()) {
|
||||
throw new SearchServiceError('No content extracted from results');
|
||||
}
|
||||
@@ -91,18 +221,27 @@ export function createSearchService({ exa, openrouter, broadcast }) {
|
||||
broadcast('📝 Generating summary with OpenRouter...', 'info');
|
||||
|
||||
let summary;
|
||||
|
||||
try {
|
||||
summary = await summarizeSources({
|
||||
const summaryResult = await summarizeSources({
|
||||
openrouter,
|
||||
text: extractedContent,
|
||||
question,
|
||||
});
|
||||
summary = summaryResult.data;
|
||||
cost.push({
|
||||
type:'openrouter_rank',
|
||||
amount: summaryResult.cost,
|
||||
prompt_tokens: summaryResult.prompt_tokens,
|
||||
completion_tokens: summaryResult.completion_tokens,
|
||||
model: 'openai/gpt-oss-120b:nitro'
|
||||
});
|
||||
} catch (error) {
|
||||
throw new SearchServiceError('Failed to generate summary', 500, error);
|
||||
}
|
||||
|
||||
if (!summary?.sources) {
|
||||
throw new SearchServiceError('Failed to generate summary');
|
||||
throw new SearchServiceError('Failed to generate summary for query: ' + question + '. No sources returned:'+ JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
broadcast(`✅ Generated summaries for ${summary.sources.length} sources`, 'success', {
|
||||
@@ -117,18 +256,54 @@ export function createSearchService({ exa, openrouter, broadcast }) {
|
||||
broadcast,
|
||||
});
|
||||
|
||||
broadcast('🔧 Enhancing summaries with detailed content...', 'info');
|
||||
enrichSourcesWithDetails(summary.sources, detailedContents);
|
||||
for (const detailed of detailedContents) {
|
||||
if (detailed.cost) {
|
||||
const outputTokens = countTokens(JSON.stringify(detailed.content));
|
||||
cost.push({
|
||||
type:'exa_get_content',
|
||||
amount: detailed.cost,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: outputTokens
|
||||
});
|
||||
}
|
||||
//if (detailed.cost2) {
|
||||
// cost.push(detailed.cost2);
|
||||
//}
|
||||
}
|
||||
|
||||
broadcast('📊 Generating final summary...', 'info');
|
||||
|
||||
let finalSummary;
|
||||
|
||||
try {
|
||||
finalSummary = await summarizeFinalAnswer({
|
||||
const finalSummaryResult = await summarizeFinalAnswer({
|
||||
openrouter,
|
||||
text: formatSummarySources(summary.sources),
|
||||
text: formatSummarySources(summary.sources,supplementaryResults),
|
||||
question,
|
||||
});
|
||||
finalSummary = finalSummaryResult.data;
|
||||
|
||||
// Push each step separately for detailed cost tracking
|
||||
if (finalSummaryResult.steps && Array.isArray(finalSummaryResult.steps)) {
|
||||
for (const step of finalSummaryResult.steps) {
|
||||
cost.push({
|
||||
type: `openrouter_${step.name}`,
|
||||
amount: step.cost,
|
||||
prompt_tokens: step.promptTokens,
|
||||
completion_tokens: step.completionTokens,
|
||||
model: step.model,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback for backward compatibility
|
||||
cost.push({
|
||||
type: 'openrouter_final_summary',
|
||||
amount: finalSummaryResult.totalCost,
|
||||
prompt_tokens: finalSummaryResult.totalPromptTokens,
|
||||
completion_tokens: finalSummaryResult.totalCompletionTokens,
|
||||
model: 'openai/gpt-oss-120b:nitro',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw new SearchServiceError('Failed to generate final summary', 500, error);
|
||||
}
|
||||
@@ -142,6 +317,9 @@ export function createSearchService({ exa, openrouter, broadcast }) {
|
||||
sourcesCount: finalSummary.mostRelevantSources?.length || 0,
|
||||
});
|
||||
|
||||
// Attach cost breakdown to the final summary
|
||||
finalSummary.costBreakdown = buildCostBreakdown(cost);
|
||||
|
||||
return finalSummary;
|
||||
},
|
||||
};
|
||||
|
||||
88
src/utils/htmlConsoleRenderer.js
Normal file
88
src/utils/htmlConsoleRenderer.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
/**
|
||||
* Simple HTML to console renderer using chalk for styling.
|
||||
* Supports: <b>, <i>, <u>, <ul>, <li>, <span style="color:...">, <p>, <div>, <hr/>
|
||||
*/
|
||||
|
||||
// Color mapping for span styles
|
||||
const colorMap = {
|
||||
red: chalk.red,
|
||||
blue: chalk.blue,
|
||||
green: chalk.green,
|
||||
yellow: chalk.yellow,
|
||||
cyan: chalk.cyan,
|
||||
magenta: chalk.magenta,
|
||||
white: chalk.white,
|
||||
gray: chalk.gray,
|
||||
grey: chalk.gray,
|
||||
black: chalk.black,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse HTML string and convert to styled console output
|
||||
* @param {string} html - HTML string to render
|
||||
* @returns {string} - Formatted string with chalk styling
|
||||
*/
|
||||
export function renderHTML(html) {
|
||||
if (!html) return '';
|
||||
|
||||
let text = html;
|
||||
|
||||
// Handle <hr/> as a line
|
||||
text = text.replace(/<hr\s*\/?>/gi, '\n' + '─'.repeat(60) + '\n');
|
||||
|
||||
// Handle <span style="color:...">content</span>
|
||||
text = text.replace(/<span\s+style\s*=\s*["']color:\s*(\w+)["']\s*>([\s\S]*?)<\/span>/gi, (match, color, content) => {
|
||||
const colorFn = colorMap[color.toLowerCase()];
|
||||
return colorFn ? colorFn(content) : content;
|
||||
});
|
||||
|
||||
// Handle <b>content</b> - bold
|
||||
text = text.replace(/<b>([\s\S]*?)<\/b>/gi, (match, content) => chalk.bold(content));
|
||||
|
||||
// Handle <i>content</i> - italic (dimmed as terminal alternative)
|
||||
text = text.replace(/<i>([\s\S]*?)<\/i>/gi, (match, content) => chalk.italic(content));
|
||||
|
||||
// Handle <u>content</u> - underline
|
||||
text = text.replace(/<u>([\s\S]*?)<\/u>/gi, (match, content) => chalk.underline(content));
|
||||
|
||||
// Handle <li>content</li> - list item with bullet
|
||||
text = text.replace(/<li>([\s\S]*?)<\/li>/gi, (match, content) => ` • ${content.trim()}\n`);
|
||||
|
||||
// Handle <ul>content</ul> - unordered list (just preserve content, items will be formatted)
|
||||
text = text.replace(/<ul>([\s\S]*?)<\/ul>/gi, (match, content) => `${content}\n`);
|
||||
|
||||
// Handle <p>content</p> - paragraph with spacing
|
||||
text = text.replace(/<p>([\s\S]*?)<\/p>/gi, (match, content) => `${content.trim()}\n\n`);
|
||||
|
||||
// Handle <div>content</div> - block with newline
|
||||
text = text.replace(/<div>([\s\S]*?)<\/div>/gi, (match, content) => `${content.trim()}\n\n`);
|
||||
|
||||
// Handle <pre>content</pre> - preformatted text with monospace styling
|
||||
text = text.replace(/<pre>([\s\S]*?)<\/pre>/gi, (match, content) => {
|
||||
// Preserve internal whitespace and newlines, apply dimmed styling for code-like appearance
|
||||
const lines = content.split('\n');
|
||||
const formattedLines = lines.map(line => chalk.dim(line));
|
||||
return '\n' + formattedLines.join('\n') + '\n\n';
|
||||
});
|
||||
|
||||
// Clean up any remaining HTML tags that weren't processed
|
||||
text = text.replace(/<[^>]+>/g, '');
|
||||
|
||||
// Clean up excessive blank lines (more than 2 consecutive newlines)
|
||||
text = text.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// Trim extra whitespace at start and end
|
||||
text = text.trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render HTML and print to console
|
||||
* @param {string} html - HTML string to render
|
||||
*/
|
||||
export function printHTML(html) {
|
||||
console.log(renderHTML(html));
|
||||
}
|
||||
Reference in New Issue
Block a user