Compare commits

...

10 Commits

Author SHA1 Message Date
sebseb7
863f45f666 chore: @modelcontextprotocol/sdk als Abhängigkeit hinzugefügt 2026-04-05 03:20:20 +02:00
sebseb7
38e702ec2f feat: Zusammenfassung der endgültigen Antwort in zwei Schritten umgestaltet
- Zerlegung von `summarizeFinalAnswer` in zwei separate LLM-Aufrufe:
  1. Zusammenfassung, Quellen und vorgeschlagene Suchen
  2. Anreicherung mit Emojis und HTML-Tags
- Verbesserte Prompt-Formulierung für detailliertere Zusammenfassungen
- Verwendung eines günstigeren Modells für den Formatierungsschritt
- Hinzufügen separater Kostenprotokollierung pro Schritt
- Aktualisierung der JSON-Schema-Antwortstruktur
2026-04-05 02:04:35 +02:00
sebseb7
92845a5a4c feat: Füge Zusammenfassung der Suchergebnisse mit summarizeDetail hinzu
- Neue Funktion summarizeDetail in openRouterService.js implementiert
- Verwendet OpenRouter API direkt mit JSON-Schema-Antwortformat
- Integriert die Zusammenfassung in searchService.js für detaillierte Inhalte
- Filtert relevante Informationen aus den Suchergebnissen basierend auf der ursprünglichen Frage
2026-04-05 01:14:11 +02:00
sebseb7
88015fbcae feat: UI-Verbesserungen und Model-Informationen in Kostenaufschlüsselung
- Vorgeschlagene Folgesuchen im UI nach oben verschoben
- Spalte "Modell" zur Kostenaufschlüsselungstabelle hinzugefügt
- Modellnamen für OpenRouter-API-Aufrufe (Rephrase, Rank, Final Summary) ergänzt
- Datenstruktur für Kostenaufschlüsselung um Modell-Information erweitert
2026-04-05 01:03:04 +02:00
sebseb7
e0a602135a chore: Lokalisierung der UI-Texte ins Deutsche
- Übersetzung aller statischen Texte in der Benutzeroberfläche von Englisch nach Deutsch
- Inklusive Schaltflächen, Überschriften, Fehlermeldungen, Statusmeldungen und Tabellenüberschriften
2026-04-05 00:53:27 +02:00
sebseb7
5f26029cfe feat: Vorschläge für Folgesuchen hinzufügen
- CSS-Stile für vorgeschlagene Suchschaltflächen hinzugefügt
- HTML-Container für die Anzeige vorgeschlagener Suchen hinzugefügt
- Logik zur Darstellung und Interaktion mit vorgeschlagenen Suchen implementiert
- OpenRouter-Prompt um Hinweis auf Folgesuchen erweitert
- JSON-Schema um Feld 'suggestedSearches' ergänzt
2026-04-05 00:37:51 +02:00
sebseb7
d2920fd39a token count for search 2026-04-04 21:51:52 +02:00
sebseb7
92b313fa79 cli html renderer 2026-04-04 16:51:00 +02:00
sebseb7
2e9a5e9e7f cli 2026-04-04 16:33:33 +02:00
sebseb7
27180aa2c3 log 2026-04-04 15:13:02 +02:00
18 changed files with 1692 additions and 77 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules
.env
.env
logs

99
README.md Normal file
View 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
View 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
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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
View 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();

View File

@@ -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
View 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
View 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');
}

View File

@@ -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) {

View File

@@ -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);
});
}

View 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;
}

View 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;
}

View File

@@ -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,
},
};
}

View File

@@ -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`;

View File

@@ -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;
},
};

View 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));
}