unified-ai-router 3.1.7 โ 3.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +0 -1
- package/package.json +1 -1
- package/readme.md +8 -84
- package/vercel-project/api/index.js +0 -109
- package/vercel-project/api/search.js +0 -114
- package/vercel-project/public/app.js +0 -351
- package/vercel-project/public/index.html +0 -44
- package/vercel-project/public/photo.jpg +0 -0
- package/vercel-project/public/style.css +0 -243
- package/vercel-project/src/config.js +0 -74
- package/vercel-project/src/telegram.js +0 -161
- package/vercel.json +0 -27
package/.env.example
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unified-ai-router",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.9",
|
|
4
4
|
"description": "A unified interface for multiple LLM providers with automatic fallback. This project includes an OpenAI-compatible server and a deployable Telegram bot with a Mini App interface. It supports major providers like OpenAI, Google, Grok, and more, ensuring reliability and flexibility for your AI applications.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "mlibre",
|
package/readme.md
CHANGED
|
@@ -15,16 +15,12 @@ It supports major providers like OpenAI, Google, Grok, and more, ensuring reliab
|
|
|
15
15
|
- [๐งช Testing](#-testing)
|
|
16
16
|
- [๐ง Supported Providers](#-supported-providers)
|
|
17
17
|
- [๐ API Keys](#-api-keys)
|
|
18
|
-
- [๐ผ Vercel Deployment (Telegram Bot)](#-vercel-deployment-telegram-bot)
|
|
19
|
-
- [๐ Prerequisites](#-prerequisites)
|
|
20
|
-
- [๐ Deployment Steps](#-deployment-steps)
|
|
21
|
-
- [๐ฑ Enable Telegram Mini App](#-enable-telegram-mini-app)
|
|
22
18
|
- [๐ Project Structure](#-project-structure)
|
|
23
19
|
- [๐ License](#-license)
|
|
24
20
|
|
|
25
21
|
## ๐ Features
|
|
26
22
|
|
|
27
|
-
- **Multi-Provider Support**: Works with OpenAI, Google, Grok, OpenRouter, Z.ai, Qroq, Cohere,
|
|
23
|
+
- **Multi-Provider Support**: Works with OpenAI, Google, Grok, OpenRouter, Z.ai, Qroq, Cohere, Cerebras, and LLM7
|
|
28
24
|
- **Automatic Fallback**: If one provider fails, automatically tries the next
|
|
29
25
|
- **Simple API**: Easy-to-use interface for all supported providers
|
|
30
26
|
- **OpenAI-Compatible Server**: Drop-in replacement for the OpenAI API, enabling easy integration with existing tools and clients
|
|
@@ -85,15 +81,15 @@ console.log(response);
|
|
|
85
81
|
The OpenAI-compatible server provides a drop-in replacement for the OpenAI API. It routes requests through the unified router with fallback logic, ensuring high availability.
|
|
86
82
|
The server uses the provider configurations defined in [provider.js](provider.js) file, and requires API keys set in a `.env` file.
|
|
87
83
|
|
|
88
|
-
1.
|
|
89
|
-
|
|
90
|
-
2. Copy the example environment file:
|
|
84
|
+
1. Copy the example environment file:
|
|
91
85
|
|
|
92
86
|
```bash
|
|
93
87
|
cp .env.example .env
|
|
94
88
|
```
|
|
95
89
|
|
|
96
|
-
|
|
90
|
+
2. Edit `.env` and add your API keys for the desired providers (see [๐ API Keys](#-api-keys) for sources).
|
|
91
|
+
|
|
92
|
+
3. Configure your providers in `provider.js`. Add new provider or modify existing ones with the appropriate `name`, `apiKey` (referencing the corresponding env variable), `model`, and `apiUrl` for the providers you want to use.
|
|
97
93
|
|
|
98
94
|
To start the server locally, run:
|
|
99
95
|
|
|
@@ -136,7 +132,6 @@ node tests/tools.js
|
|
|
136
132
|
- Z.ai
|
|
137
133
|
- Qroq
|
|
138
134
|
- Cohere
|
|
139
|
-
- Vercel
|
|
140
135
|
- Cerebras
|
|
141
136
|
- LLM7
|
|
142
137
|
- Any Other OpenAI Compatible Server
|
|
@@ -156,77 +151,6 @@ Get your API keys from the following providers:
|
|
|
156
151
|
- **Cerebras**: [cloud.cerebras.ai](https://cloud.cerebras.ai)
|
|
157
152
|
- **LLM7**: [token.llm7.io](https://token.llm7.io/)
|
|
158
153
|
|
|
159
|
-
## ๐ผ Vercel Deployment (Telegram Bot)
|
|
160
|
-
|
|
161
|
-
This section describes how to deploy the AIRouter as a Telegram bot using Vercel. This is a separate deployment from the core library.
|
|
162
|
-
|
|
163
|
-
### ๐ Prerequisites
|
|
164
|
-
|
|
165
|
-
- A Telegram Bot Token (@BotFather)
|
|
166
|
-
- API keys for various AI providers
|
|
167
|
-
- Vercel account
|
|
168
|
-
|
|
169
|
-
### ๐ Deployment Steps
|
|
170
|
-
|
|
171
|
-
```bash
|
|
172
|
-
# Create the vercel project: vercel.com
|
|
173
|
-
# name: ai-router
|
|
174
|
-
|
|
175
|
-
npm i -g vercel
|
|
176
|
-
vercel login
|
|
177
|
-
|
|
178
|
-
nano .env
|
|
179
|
-
TELEGRAM_BOT_TOKEN=TOKEN
|
|
180
|
-
GOOGLE_API_KEY=API_KEY
|
|
181
|
-
OPENROUTER_API_KEY=API_KEY
|
|
182
|
-
ZAI_API_KEY=API_KEY
|
|
183
|
-
QROQ_API_KEY=API_KEY
|
|
184
|
-
COHERE_API_KEY=API_KEY
|
|
185
|
-
VERCEL_AI_GATEWAY_API_KEY=API_KEY
|
|
186
|
-
CEREBRAS_API_KEY=API_KEY
|
|
187
|
-
LLM7_API_KEY=API_KEY
|
|
188
|
-
VERCEL_URL=VERCEL_URL
|
|
189
|
-
SEARX_URL=SEARX_URL
|
|
190
|
-
|
|
191
|
-
vercel env add TELEGRAM_BOT_TOKEN
|
|
192
|
-
vercel env add GOOGLE_API_KEY
|
|
193
|
-
vercel env add OPENROUTER_API_KEY
|
|
194
|
-
vercel env add ZAI_API_KEY
|
|
195
|
-
vercel env add QROQ_API_KEY
|
|
196
|
-
vercel env add COHERE_API_KEY
|
|
197
|
-
vercel env add VERCEL_AI_GATEWAY_API_KEY
|
|
198
|
-
vercel env add CEREBRAS_API_KEY
|
|
199
|
-
vercel env add LLM7_API_KEY
|
|
200
|
-
vercel env add VERCEL_URL
|
|
201
|
-
vercel env add SEARX_URL
|
|
202
|
-
|
|
203
|
-
# Deploy to Vercel
|
|
204
|
-
vercel
|
|
205
|
-
vercel --prod
|
|
206
|
-
|
|
207
|
-
# Check logs
|
|
208
|
-
vercel logs https://ai-router-flame.vercel.app
|
|
209
|
-
|
|
210
|
-
# Register webhook for Telegram
|
|
211
|
-
# https://ai-router-flame.vercel.app/api?register_webhook=true
|
|
212
|
-
curl "https://ai-router-flame.vercel.app/api?register_webhook=true"
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
### ๐ฑ Enable Telegram Mini App
|
|
216
|
-
|
|
217
|
-
After deploying the bot, you need to configure the Telegram Mini App and menu button:
|
|
218
|
-
|
|
219
|
-
**Configure Mini App:**
|
|
220
|
-
|
|
221
|
-
- Go to [@BotFather](https://t.me/botfather)
|
|
222
|
-
- Send `/mybots` and select your bot
|
|
223
|
-
- Go to `Bot Settings` โ `Configure Mini App`
|
|
224
|
-
- Set the Mini App URL to: `https://ai-router-flame.vercel.app`
|
|
225
|
-
|
|
226
|
-
Once configured, users can access the Mini App by sending `/start` or `/app` to your bot.
|
|
227
|
-
|
|
228
|
-
An example of a deployed bot is accessible on Telegram: [https://t.me/freePulseAIbot](https://t.me/freePulseAIbot)
|
|
229
|
-
|
|
230
154
|
## ๐ Project Structure
|
|
231
155
|
|
|
232
156
|
- `main.js` - Core AIRouter library implementing the unified interface and fallback logic
|
|
@@ -234,9 +158,9 @@ An example of a deployed bot is accessible on Telegram: [https://t.me/freePulseA
|
|
|
234
158
|
- `openai-compatible-server/index.js` - OpenAI-compatible API server
|
|
235
159
|
- `tests/` - Comprehensive tests for the library, server, and tools
|
|
236
160
|
- `bruno/` - Bruno API collection for testing endpoints
|
|
237
|
-
- `
|
|
238
|
-
- `api/index.js` - Telegram webhook handler
|
|
239
|
-
- `api/search.js` - Search proxy endpoint
|
|
161
|
+
- `cloud-flare/` - Ready-to-deploy Cloudflare Pages setup for the Telegram bot
|
|
162
|
+
- `functions/api/index.js` - Telegram webhook handler
|
|
163
|
+
- `functions/api/search.js` - Search proxy endpoint
|
|
240
164
|
- `public/` - Mini App frontend (HTML, CSS, JS)
|
|
241
165
|
- `src/config.js` - Bot configuration
|
|
242
166
|
- `src/telegram.js` - Telegram API integration
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
const TelegramClient = require( "../src/telegram" );
|
|
2
|
-
const { productionUrl, token } = require( "../src/config" );
|
|
3
|
-
const telegramClient = new TelegramClient();
|
|
4
|
-
const crypto = require( "crypto" );
|
|
5
|
-
|
|
6
|
-
// --- Function to validate Mini App data ---
|
|
7
|
-
function validateTelegramData ( telegramData, botToken )
|
|
8
|
-
{
|
|
9
|
-
if ( !telegramData )
|
|
10
|
-
{
|
|
11
|
-
return false;
|
|
12
|
-
}
|
|
13
|
-
const data = new URLSearchParams( telegramData );
|
|
14
|
-
const hash = data.get( "hash" );
|
|
15
|
-
data.delete( "hash" );
|
|
16
|
-
|
|
17
|
-
const dataCheckString = Array.from( data.entries() )
|
|
18
|
-
.sort( ( [a], [b] ) => { return a.localeCompare( b ) })
|
|
19
|
-
.map( ( [key, value] ) => { return `${key}=${value}` })
|
|
20
|
-
.join( "\n" );
|
|
21
|
-
|
|
22
|
-
const secretKey = crypto.createHmac( "sha256", "WebAppData" ).update( botToken ).digest();
|
|
23
|
-
const calculatedHash = crypto.createHmac( "sha256", secretKey ).update( dataCheckString ).digest( "hex" );
|
|
24
|
-
|
|
25
|
-
return calculatedHash === hash;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
module.exports = async ( req, res ) =>
|
|
29
|
-
{
|
|
30
|
-
// --- Handle Mini App API requests ---
|
|
31
|
-
if ( req.url.startsWith( "/api/chat" ) )
|
|
32
|
-
{
|
|
33
|
-
try
|
|
34
|
-
{
|
|
35
|
-
const telegramData = req.headers["telegram-data"];
|
|
36
|
-
if ( !validateTelegramData( telegramData, token ) )
|
|
37
|
-
{
|
|
38
|
-
return res.status( 403 ).json({ error: "Unauthorized: Invalid Telegram data" });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Expect 'messages' array instead of 'prompt'
|
|
42
|
-
const { messages } = req.body;
|
|
43
|
-
if ( !messages || !Array.isArray( messages ) || messages.length === 0 )
|
|
44
|
-
{
|
|
45
|
-
return res.status( 400 ).json({ error: "Messages array is required" });
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const params = new URLSearchParams( telegramData );
|
|
49
|
-
const user = JSON.parse( params.get( "user" ) );
|
|
50
|
-
console.log( `Processing chat for user ${user.id}. History length: ${messages.length}` );
|
|
51
|
-
|
|
52
|
-
// Use your existing AI Router with the full message history
|
|
53
|
-
const aiResponse = await telegramClient.aiRouter.chatCompletion( messages );
|
|
54
|
-
|
|
55
|
-
return res.status( 200 ).json({ response: aiResponse.content });
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
catch ( error )
|
|
59
|
-
{
|
|
60
|
-
console.error( "Error in /api/chat:", error );
|
|
61
|
-
return res.status( 500 ).json({ error: "Internal Server Error" });
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ... (rest of the webhook handling code remains the same) ...
|
|
66
|
-
if ( req.query.register_webhook === "true" )
|
|
67
|
-
{
|
|
68
|
-
try
|
|
69
|
-
{
|
|
70
|
-
const response = await telegramClient.registerWebhook( productionUrl );
|
|
71
|
-
return res.status( 200 ).json({ success: true, message: "Webhook setup completed", response });
|
|
72
|
-
}
|
|
73
|
-
catch ( error )
|
|
74
|
-
{
|
|
75
|
-
console.error( "Error setting up webhook:", error );
|
|
76
|
-
return res.status( 500 ).json({ success: false, error: error.message });
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
else if ( req.query.unregister_webhook === "true" )
|
|
80
|
-
{
|
|
81
|
-
try
|
|
82
|
-
{
|
|
83
|
-
await telegramClient.unRegisterWebhook();
|
|
84
|
-
return res.status( 200 ).json({ success: true, message: "Webhook unregistered" });
|
|
85
|
-
}
|
|
86
|
-
catch ( error )
|
|
87
|
-
{
|
|
88
|
-
console.error( "Error unregistering webhook:", error );
|
|
89
|
-
return res.status( 500 ).json({ success: false, error: error.message });
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
try
|
|
93
|
-
{
|
|
94
|
-
const update = req.body;
|
|
95
|
-
if ( !update )
|
|
96
|
-
{
|
|
97
|
-
console.error( "Received empty request body" );
|
|
98
|
-
return res.status( 400 ).send( "Bad Request: Empty body" );
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
await telegramClient.handleUpdate( update );
|
|
102
|
-
res.status( 200 ).send( "OK" );
|
|
103
|
-
}
|
|
104
|
-
catch ( error )
|
|
105
|
-
{
|
|
106
|
-
console.error( "Error processing update:", error );
|
|
107
|
-
res.status( 500 ).send( "Internal Server Error" );
|
|
108
|
-
}
|
|
109
|
-
};
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
module.exports = async ( req, res ) =>
|
|
2
|
-
{
|
|
3
|
-
try
|
|
4
|
-
{
|
|
5
|
-
const q = req.query && req.query.q || req.body && req.body.q;
|
|
6
|
-
if ( !q )
|
|
7
|
-
{
|
|
8
|
-
return res.status( 400 ).json({ error: "q (query) parameter is required" });
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const base = ( process.env.SEARX_URL || "https://searx.perennialte.ch" ).replace( /\/+$/, "" );
|
|
12
|
-
const url = `${base}/search?q=${encodeURIComponent( q )}&format=json&language=all`;
|
|
13
|
-
|
|
14
|
-
// Use sensible browser-like headers and prefer the client's User-Agent when available
|
|
15
|
-
const incomingUA = req.headers && req.headers["user-agent"] || "";
|
|
16
|
-
const ua = incomingUA || "Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0";
|
|
17
|
-
const acceptHeader = req.headers && req.headers.accept || "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
|
|
18
|
-
const acceptLang = req.headers && req.headers["accept-language"] || "en-US,en;q=0.5";
|
|
19
|
-
|
|
20
|
-
const fetchHeaders = {
|
|
21
|
-
"User-Agent": ua,
|
|
22
|
-
"Accept": acceptHeader,
|
|
23
|
-
"Accept-Language": acceptLang,
|
|
24
|
-
"DNT": req.headers && req.headers["dnt"] || "1",
|
|
25
|
-
"Sec-GPC": req.headers && req.headers["sec-gpc"] || "1",
|
|
26
|
-
"Upgrade-Insecure-Requests": "1",
|
|
27
|
-
"Sec-Fetch-Dest": "document",
|
|
28
|
-
"Sec-Fetch-Mode": "navigate",
|
|
29
|
-
"Sec-Fetch-Site": "none",
|
|
30
|
-
"Sec-Fetch-User": "?1",
|
|
31
|
-
"Referer": base,
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// Try a few times on 429 or transient failures with exponential backoff
|
|
35
|
-
const maxAttempts = 3;
|
|
36
|
-
let attempt = 0;
|
|
37
|
-
let r = null;
|
|
38
|
-
while ( attempt < maxAttempts )
|
|
39
|
-
{
|
|
40
|
-
attempt++;
|
|
41
|
-
try
|
|
42
|
-
{
|
|
43
|
-
// Use fetch available on Node 18+ / Vercel runtime
|
|
44
|
-
r = await fetch( url, {
|
|
45
|
-
method: "GET",
|
|
46
|
-
headers: fetchHeaders,
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
if ( r.ok )
|
|
50
|
-
{
|
|
51
|
-
// success โ parse below
|
|
52
|
-
break;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// If rate-limited, wait and retry (honor Retry-After header if present)
|
|
56
|
-
if ( r.status === 429 )
|
|
57
|
-
{
|
|
58
|
-
const retryAfter = r.headers.get( "retry-after" );
|
|
59
|
-
const waitMs = retryAfter ? parseInt( retryAfter, 10 ) * 1000 : 200 * attempt ; // small backoff
|
|
60
|
-
await new Promise( resolve => { return setTimeout( resolve, waitMs ) });
|
|
61
|
-
continue; // retry
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// non-429 non-ok โ parse body if possible and return helpful info
|
|
65
|
-
const t = await r.text().catch( () => { return null });
|
|
66
|
-
return res.status( 502 ).json({
|
|
67
|
-
error: "Searx returned non-OK",
|
|
68
|
-
status: r.status,
|
|
69
|
-
details: t,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
catch ( err )
|
|
73
|
-
{
|
|
74
|
-
// transient network failure โ small backoff and retry
|
|
75
|
-
if ( attempt < maxAttempts )
|
|
76
|
-
{
|
|
77
|
-
await new Promise( resolve => { return setTimeout( resolve, 150 * attempt ) });
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
else
|
|
81
|
-
{
|
|
82
|
-
console.error( "search proxy fetch error:", err );
|
|
83
|
-
return res.status( 502 ).json({ error: "Searx fetch failed", details: String( err && err.message ? err.message : err ) });
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
} // end retry loop
|
|
87
|
-
|
|
88
|
-
if ( !r )
|
|
89
|
-
{
|
|
90
|
-
return res.status( 502 ).json({ error: "No response from Searx" });
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if ( !r.ok )
|
|
94
|
-
{
|
|
95
|
-
const t = await r.text().catch( () => { return null });
|
|
96
|
-
return res.status( 502 ).json({
|
|
97
|
-
error: "Searx returned non-OK after retries",
|
|
98
|
-
status: r.status,
|
|
99
|
-
details: t,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const json = await r.json();
|
|
104
|
-
|
|
105
|
-
// Lightweight caching headers for Vercel CDN
|
|
106
|
-
res.setHeader( "Cache-Control", "s-maxage=60, stale-while-revalidate=120" );
|
|
107
|
-
return res.status( 200 ).json( json );
|
|
108
|
-
}
|
|
109
|
-
catch ( err )
|
|
110
|
-
{
|
|
111
|
-
console.error( "search proxy error:", err );
|
|
112
|
-
return res.status( 500 ).json({ error: err.message });
|
|
113
|
-
}
|
|
114
|
-
};
|
|
@@ -1,351 +0,0 @@
|
|
|
1
|
-
// public/app.js - Vanilla JS front-end for Telegram Mini App (drop-in)
|
|
2
|
-
// Updated: sanitize assistant text, friendly search error handling, theme variables mapping,
|
|
3
|
-
// removed separate search toggle (single composer + include-search checkbox used)
|
|
4
|
-
|
|
5
|
-
( function ()
|
|
6
|
-
{
|
|
7
|
-
const WebApp = window.Telegram?.WebApp;
|
|
8
|
-
if ( !WebApp )
|
|
9
|
-
{
|
|
10
|
-
console.warn( "Telegram WebApp object not available." );
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// UI elements
|
|
14
|
-
const messagesEl = document.getElementById( "messages" );
|
|
15
|
-
const inputEl = document.getElementById( "input" );
|
|
16
|
-
const formEl = document.getElementById( "composer" );
|
|
17
|
-
const sendBtn = document.getElementById( "send" );
|
|
18
|
-
const fullscreenBtn = document.getElementById( "fullscreen-btn" );
|
|
19
|
-
const searchPanel = document.getElementById( "search-panel" );
|
|
20
|
-
const searchResultsEl = document.getElementById( "search-results" );
|
|
21
|
-
const searchStatusEl = document.getElementById( "search-status" );
|
|
22
|
-
const includeSearchCheckbox = document.getElementById( "include-search" );
|
|
23
|
-
|
|
24
|
-
let conversation = [];
|
|
25
|
-
let lastSearchResults = [];
|
|
26
|
-
|
|
27
|
-
// Initialize WebApp
|
|
28
|
-
try
|
|
29
|
-
{
|
|
30
|
-
WebApp?.ready?.();
|
|
31
|
-
WebApp?.expand?.();
|
|
32
|
-
}
|
|
33
|
-
catch ( e ) {}
|
|
34
|
-
|
|
35
|
-
// Apply theme params to CSS variables (use Telegram's themeParams fields)
|
|
36
|
-
function applyTheme ()
|
|
37
|
-
{
|
|
38
|
-
try
|
|
39
|
-
{
|
|
40
|
-
const t = WebApp?.themeParams || {};
|
|
41
|
-
// Map many theme params to CSS variables so style.css can pick them up
|
|
42
|
-
const mappings = {
|
|
43
|
-
"--tg-theme-bg-color": t.bg_color || "#ffffff",
|
|
44
|
-
"--tg-theme-text-color": t.text_color || "#111827",
|
|
45
|
-
"--tg-theme-hint-color": t.hint_color || "#6b7280",
|
|
46
|
-
"--tg-theme-link-color": t.link_color || "#2563eb",
|
|
47
|
-
"--tg-theme-button-color": t.button_color || "#2563eb",
|
|
48
|
-
"--tg-theme-button-text-color": t.button_text_color || "#ffffff",
|
|
49
|
-
"--tg-theme-secondary-bg-color": t.secondary_bg_color || "#f3f4f6",
|
|
50
|
-
"--tg-theme-header-bg-color": t.header_bg_color || "#ffffff",
|
|
51
|
-
"--tg-theme-bottom-bar-bg-color": t.bottom_bar_bg_color || "#ffffff",
|
|
52
|
-
"--tg-theme-accent-text-color": t.accent_text_color || "#111827",
|
|
53
|
-
"--tg-theme-section-bg-color": t.section_bg_color || "#ffffff",
|
|
54
|
-
"--tg-theme-section-header-text-color": t.section_header_text_color || "#111827",
|
|
55
|
-
"--tg-theme-section-separator-color": t.section_separator_color || "#e5e7eb",
|
|
56
|
-
"--tg-theme-subtitle-text-color": t.subtitle_text_color || "#6b7280",
|
|
57
|
-
"--tg-theme-destructive-text-color": t.destructive_text_color || "#ef4444",
|
|
58
|
-
};
|
|
59
|
-
Object.entries( mappings ).forEach( ( [k, v] ) => { return document.documentElement.style.setProperty( k, v ) });
|
|
60
|
-
}
|
|
61
|
-
catch ( e )
|
|
62
|
-
{
|
|
63
|
-
console.warn( "applyTheme failed", e );
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
applyTheme();
|
|
67
|
-
WebApp?.onEvent && WebApp.onEvent( "themeChanged", applyTheme );
|
|
68
|
-
|
|
69
|
-
// Cloud storage helpers
|
|
70
|
-
function saveHistory ()
|
|
71
|
-
{
|
|
72
|
-
try
|
|
73
|
-
{
|
|
74
|
-
WebApp.CloudStorage.setItem( "chat_history", JSON.stringify( conversation ), ( err ) =>
|
|
75
|
-
{
|
|
76
|
-
if ( err ) console.warn( "CloudStorage save failed", err );
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
catch ( e )
|
|
80
|
-
{
|
|
81
|
-
// ignore
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
function loadHistory ()
|
|
85
|
-
{
|
|
86
|
-
try
|
|
87
|
-
{
|
|
88
|
-
WebApp.CloudStorage.getItem( "chat_history", ( err, value ) =>
|
|
89
|
-
{
|
|
90
|
-
if ( err || !value )
|
|
91
|
-
{
|
|
92
|
-
conversation = [{ role: "assistant", content: "Hi โ welcome to the new AI Router! Use the checkbox to include web results in your request." }];
|
|
93
|
-
renderMessages();
|
|
94
|
-
saveHistory();
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
try
|
|
98
|
-
{
|
|
99
|
-
conversation = JSON.parse( value );
|
|
100
|
-
}
|
|
101
|
-
catch ( e )
|
|
102
|
-
{
|
|
103
|
-
conversation = [{ role: "assistant", content: "Hi โ welcome to the new AI Router!" }];
|
|
104
|
-
}
|
|
105
|
-
renderMessages();
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
catch ( e )
|
|
109
|
-
{
|
|
110
|
-
conversation = [{ role: "assistant", content: "Hi โ welcome to the new AI Router!" }];
|
|
111
|
-
renderMessages();
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Rendering
|
|
116
|
-
function renderMessages ()
|
|
117
|
-
{
|
|
118
|
-
messagesEl.innerHTML = "";
|
|
119
|
-
conversation.forEach( ( m ) =>
|
|
120
|
-
{
|
|
121
|
-
const div = document.createElement( "div" );
|
|
122
|
-
div.className = `message ${ m.role === "user" ? "user" : "assistant"}`;
|
|
123
|
-
// allow HTML-safe line breaks - keep textContent to avoid XSS
|
|
124
|
-
div.textContent = m.content;
|
|
125
|
-
messagesEl.appendChild( div );
|
|
126
|
-
});
|
|
127
|
-
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Add message (and persist)
|
|
131
|
-
function pushMessage ( role, content )
|
|
132
|
-
{
|
|
133
|
-
conversation.push({ role, content });
|
|
134
|
-
renderMessages();
|
|
135
|
-
saveHistory();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Simple helper to sanitize assistant text:
|
|
139
|
-
// - remove leading/trailing whitespace/newlines
|
|
140
|
-
// - collapse 3+ consecutive newlines into max 2
|
|
141
|
-
// - preserve paragraph breaks up to 2 newlines
|
|
142
|
-
function sanitizeAssistantText ( text )
|
|
143
|
-
{
|
|
144
|
-
if ( !text && text !== "" ) return text;
|
|
145
|
-
// normalize CRLF
|
|
146
|
-
let s = String( text ).replace( /\r/g, "" );
|
|
147
|
-
// remove leading blank lines
|
|
148
|
-
s = s.replace( /^\s*\n+/, "" );
|
|
149
|
-
// remove trailing blank lines
|
|
150
|
-
s = s.replace( /\n+\s*$/, "" );
|
|
151
|
-
// collapse >2 blank lines into 2
|
|
152
|
-
s = s.replace( /\n{3,}/g, "\n\n" );
|
|
153
|
-
// trim surrounding whitespace
|
|
154
|
-
s = s.trim();
|
|
155
|
-
return s;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Render search results list
|
|
159
|
-
function renderSearchResults ( items )
|
|
160
|
-
{
|
|
161
|
-
searchResultsEl.innerHTML = "";
|
|
162
|
-
if ( !items || items.length === 0 )
|
|
163
|
-
{
|
|
164
|
-
searchResultsEl.innerHTML = "<li class=\"no-results\">No results</li>";
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
items.forEach( ( r, idx ) =>
|
|
168
|
-
{
|
|
169
|
-
const li = document.createElement( "li" );
|
|
170
|
-
li.className = "search-item";
|
|
171
|
-
const a = document.createElement( "a" );
|
|
172
|
-
a.href = r.link || "#";
|
|
173
|
-
a.target = "_blank";
|
|
174
|
-
a.rel = "noopener noreferrer";
|
|
175
|
-
a.textContent = r.title || r.link || `Result ${idx + 1}`;
|
|
176
|
-
const p = document.createElement( "p" );
|
|
177
|
-
p.className = "snippet";
|
|
178
|
-
p.textContent = r.snippet || "";
|
|
179
|
-
li.appendChild( a );
|
|
180
|
-
li.appendChild( p );
|
|
181
|
-
searchResultsEl.appendChild( li );
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Search function that returns normalized results or throws an Error with friendly message
|
|
186
|
-
async function doSearchFetch ( query )
|
|
187
|
-
{
|
|
188
|
-
if ( !query || !query.trim() ) return [];
|
|
189
|
-
const url = `/api/search?q=${encodeURIComponent( query )}`;
|
|
190
|
-
const resp = await fetch( url, { headers: { "Telegram-Data": WebApp?.initData || "" } });
|
|
191
|
-
if ( !resp.ok )
|
|
192
|
-
{
|
|
193
|
-
// try to parse helpful info
|
|
194
|
-
let body;
|
|
195
|
-
try { body = await resp.json(); }
|
|
196
|
-
catch ( e ) { body = await resp.text().catch( () => { return null }); }
|
|
197
|
-
// map status codes to friendly messages when possible
|
|
198
|
-
if ( resp.status === 429 || body && ( body.details && String( body.details ).toLowerCase().includes( "too many requests" ) ) )
|
|
199
|
-
{
|
|
200
|
-
throw new Error( "Too Many Requests โ search rate limited. Please try again later." );
|
|
201
|
-
}
|
|
202
|
-
else
|
|
203
|
-
{
|
|
204
|
-
const message = body && ( body.error || body.details || JSON.stringify( body ) ) || `${resp.status} ${resp.statusText}`;
|
|
205
|
-
throw new Error( `Search failed: ${message}` );
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
const js = await resp.json();
|
|
209
|
-
|
|
210
|
-
// Normalize common shapes of Searx responses
|
|
211
|
-
let rawResults = [];
|
|
212
|
-
if ( Array.isArray( js.results ) && js.results.length )
|
|
213
|
-
{
|
|
214
|
-
rawResults = js.results;
|
|
215
|
-
}
|
|
216
|
-
else if ( js.categories && js.categories.general && Array.isArray( js.categories.general.results ) )
|
|
217
|
-
{
|
|
218
|
-
rawResults = js.categories.general.results;
|
|
219
|
-
}
|
|
220
|
-
else if ( Array.isArray( js ) )
|
|
221
|
-
{
|
|
222
|
-
rawResults = js;
|
|
223
|
-
}
|
|
224
|
-
else if ( Array.isArray( js.raw ) )
|
|
225
|
-
{
|
|
226
|
-
rawResults = js.raw;
|
|
227
|
-
}
|
|
228
|
-
else if ( js.results && Array.isArray( js.results ) )
|
|
229
|
-
{
|
|
230
|
-
rawResults = js.results;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const normalized = rawResults.slice( 0, 8 ).map( r =>
|
|
234
|
-
{
|
|
235
|
-
return {
|
|
236
|
-
title: r.title || r.name || r.headers?.title || r.url || r.link || "",
|
|
237
|
-
snippet: r.content || r.snippet || r.excerpt || r.description || "",
|
|
238
|
-
link: r.url || r.link || r.source || r.original_url || "",
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
return normalized;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Main submit handler
|
|
246
|
-
formEl.addEventListener( "submit", async function ( ev )
|
|
247
|
-
{
|
|
248
|
-
ev.preventDefault();
|
|
249
|
-
const text = inputEl.value.trim();
|
|
250
|
-
if ( !text ) return;
|
|
251
|
-
|
|
252
|
-
// push user message immediately
|
|
253
|
-
pushMessage( "user", text );
|
|
254
|
-
|
|
255
|
-
// If includeSearch is checked -> do search first, show results, and include them in chat request
|
|
256
|
-
if ( includeSearchCheckbox.checked )
|
|
257
|
-
{
|
|
258
|
-
// show searching placeholder and clear previous results
|
|
259
|
-
searchStatusEl.textContent = "Searchingโฆ";
|
|
260
|
-
searchResultsEl.innerHTML = "";
|
|
261
|
-
searchPanel.classList.remove( "hidden" );
|
|
262
|
-
|
|
263
|
-
try
|
|
264
|
-
{
|
|
265
|
-
const results = await doSearchFetch( text );
|
|
266
|
-
lastSearchResults = results;
|
|
267
|
-
renderSearchResults( results );
|
|
268
|
-
searchStatusEl.textContent = `Found ${results.length} result${results.length === 1 ? "" : "s"}. These will be included in your request.`;
|
|
269
|
-
}
|
|
270
|
-
catch ( err )
|
|
271
|
-
{
|
|
272
|
-
// friendly error for the user; DO NOT place raw JSON object into the assistant reply
|
|
273
|
-
const friendly = err?.message || "Search failed";
|
|
274
|
-
pushMessage( "assistant", friendly );
|
|
275
|
-
// stop here โ do not automatically call /api/chat when search failed
|
|
276
|
-
inputEl.value = "";
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// show thinking placeholder
|
|
282
|
-
pushMessage( "assistant", "Thinking..." );
|
|
283
|
-
|
|
284
|
-
// Build payload for /api/chat. If we've just done search (includeSearch checked), include snippets
|
|
285
|
-
const includeSearch = includeSearchCheckbox.checked ? lastSearchResults.slice( 0, 3 ) || [] : [];
|
|
286
|
-
|
|
287
|
-
const payload = {
|
|
288
|
-
messages: conversation.concat( [{ role: "user", content: text }] ),
|
|
289
|
-
includeSearch,
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
try
|
|
293
|
-
{
|
|
294
|
-
const resp = await fetch( "/api/chat", {
|
|
295
|
-
method: "POST",
|
|
296
|
-
headers: {
|
|
297
|
-
"Content-Type": "application/json",
|
|
298
|
-
"Telegram-Data": WebApp?.initData || "",
|
|
299
|
-
},
|
|
300
|
-
body: JSON.stringify( payload ),
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
if ( !resp.ok )
|
|
304
|
-
{
|
|
305
|
-
const data = await resp.json().catch( () => { return {} });
|
|
306
|
-
throw new Error( data.error || `${resp.status} ${resp.statusText}` );
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const body = await resp.json();
|
|
310
|
-
// sanitize response text
|
|
311
|
-
const raw = body && ( body.response || "" ) || "";
|
|
312
|
-
const sanitized = sanitizeAssistantText( raw );
|
|
313
|
-
|
|
314
|
-
// replace the last 'Thinking...' assistant message with the real response
|
|
315
|
-
if ( conversation.length > 0 && conversation[conversation.length - 1].role === "assistant" && conversation[conversation.length - 1].content === "Thinking..." )
|
|
316
|
-
{
|
|
317
|
-
conversation = conversation.slice( 0, -1 );
|
|
318
|
-
}
|
|
319
|
-
conversation.push({ role: "assistant", content: sanitized });
|
|
320
|
-
renderMessages();
|
|
321
|
-
saveHistory();
|
|
322
|
-
}
|
|
323
|
-
catch ( err )
|
|
324
|
-
{
|
|
325
|
-
console.error( "Chat error", err );
|
|
326
|
-
// replace 'Thinking...' with friendly error
|
|
327
|
-
if ( conversation.length > 0 && conversation[conversation.length - 1].role === "assistant" && conversation[conversation.length - 1].content === "Thinking..." )
|
|
328
|
-
{
|
|
329
|
-
conversation = conversation.slice( 0, -1 );
|
|
330
|
-
}
|
|
331
|
-
conversation.push({ role: "assistant", content: `Error: ${err.message}` });
|
|
332
|
-
renderMessages();
|
|
333
|
-
saveHistory();
|
|
334
|
-
}
|
|
335
|
-
finally
|
|
336
|
-
{
|
|
337
|
-
inputEl.value = "";
|
|
338
|
-
}
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
// Fullscreen button
|
|
342
|
-
fullscreenBtn.addEventListener( "click", () =>
|
|
343
|
-
{
|
|
344
|
-
if ( !WebApp ) return;
|
|
345
|
-
if ( WebApp.isFullscreen ) WebApp.exitFullscreen();
|
|
346
|
-
else WebApp.requestFullscreen();
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
// initial load
|
|
350
|
-
loadHistory();
|
|
351
|
-
})();
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
|
|
4
|
-
<head>
|
|
5
|
-
<meta charset="utf-8" />
|
|
6
|
-
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no" />
|
|
7
|
-
<title>AI Router โ New UI</title>
|
|
8
|
-
<link rel="stylesheet" href="style.css" />
|
|
9
|
-
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
|
10
|
-
</head>
|
|
11
|
-
|
|
12
|
-
<body>
|
|
13
|
-
<div class="app-shell">
|
|
14
|
-
<header class="app-header">
|
|
15
|
-
<div class="header-actions">
|
|
16
|
-
<button id="fullscreen-btn" class="icon-btn" aria-label="Toggle fullscreen">โคข</button>
|
|
17
|
-
</div>
|
|
18
|
-
</header>
|
|
19
|
-
|
|
20
|
-
<main class="chat-area">
|
|
21
|
-
<div id="messages" class="messages" role="log" aria-live="polite"></div>
|
|
22
|
-
|
|
23
|
-
<div id="search-panel" class="search-panel hidden">
|
|
24
|
-
<div id="search-status" class="search-status">Search results will appear here when you use the "Include
|
|
25
|
-
search" option.</div>
|
|
26
|
-
<ul id="search-results" class="search-results"></ul>
|
|
27
|
-
</div>
|
|
28
|
-
</main>
|
|
29
|
-
|
|
30
|
-
<form id="composer" class="composer" autocomplete="off">
|
|
31
|
-
<input id="input" type="text" placeholder="Ask anything..." aria-label="Message input" />
|
|
32
|
-
<label class="checkbox-inline" title="Include most recent search snippets when asking">
|
|
33
|
-
<input id="include-search" type="checkbox" />
|
|
34
|
-
<span class="small">Include search</span>
|
|
35
|
-
</label>
|
|
36
|
-
<button id="send" type="submit" class="send-btn">โ</button>
|
|
37
|
-
</form>
|
|
38
|
-
|
|
39
|
-
</div>
|
|
40
|
-
|
|
41
|
-
<script src="app.js"></script>
|
|
42
|
-
</body>
|
|
43
|
-
|
|
44
|
-
</html>
|
|
Binary file
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
:root {
|
|
2
|
-
--tg-theme-bg-color: #ffffff;
|
|
3
|
-
--tg-theme-text-color: #111827;
|
|
4
|
-
--tg-theme-hint-color: #6b7280;
|
|
5
|
-
--tg-theme-link-color: #2563eb;
|
|
6
|
-
--tg-theme-button-color: #2563eb;
|
|
7
|
-
--tg-theme-button-text-color: #ffffff;
|
|
8
|
-
--tg-theme-secondary-bg-color: #f3f4f6;
|
|
9
|
-
--tg-theme-header-bg-color: #ffffff;
|
|
10
|
-
--tg-theme-bottom-bar-bg-color: #ffffff;
|
|
11
|
-
--tg-theme-accent-text-color: #111827;
|
|
12
|
-
--tg-theme-section-bg-color: #ffffff;
|
|
13
|
-
--tg-theme-section-header-text-color: #111827;
|
|
14
|
-
--tg-theme-section-separator-color: #e5e7eb;
|
|
15
|
-
--tg-theme-subtitle-text-color: #6b7280;
|
|
16
|
-
--tg-theme-destructive-text-color: #ef4444;
|
|
17
|
-
|
|
18
|
-
/* Map to the variables used in the stylesheet */
|
|
19
|
-
--bg: var(--tg-theme-bg-color);
|
|
20
|
-
--text: var(--tg-theme-text-color);
|
|
21
|
-
--hint: var(--tg-theme-hint-color);
|
|
22
|
-
--accent: var(--tg-theme-button-color);
|
|
23
|
-
--muted: var(--tg-theme-secondary-bg-color);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
* {
|
|
27
|
-
box-sizing: border-box
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
html,
|
|
31
|
-
body,
|
|
32
|
-
#root {
|
|
33
|
-
height: 100%
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
body {
|
|
37
|
-
font-family: inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
38
|
-
margin: 0;
|
|
39
|
-
height: 100vh;
|
|
40
|
-
background: var(--bg);
|
|
41
|
-
color: var(--text);
|
|
42
|
-
-webkit-font-smoothing: antialiased;
|
|
43
|
-
-moz-osx-font-smoothing: grayscale;
|
|
44
|
-
display: flex;
|
|
45
|
-
align-items: stretch;
|
|
46
|
-
justify-content: center;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
.app-shell {
|
|
50
|
-
width: 100%;
|
|
51
|
-
max-width: 820px;
|
|
52
|
-
height: 100vh;
|
|
53
|
-
display: flex;
|
|
54
|
-
flex-direction: column;
|
|
55
|
-
background: var(--bg);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
.app-header {
|
|
59
|
-
display: flex;
|
|
60
|
-
align-items: center;
|
|
61
|
-
justify-content: space-between;
|
|
62
|
-
padding: 12px 14px;
|
|
63
|
-
border-bottom: 1px solid var(--tg-theme-section-separator-color);
|
|
64
|
-
/* use Telegram-provided header background color instead of a hard white gradient */
|
|
65
|
-
background: var(--tg-theme-header-bg-color);
|
|
66
|
-
/* subtle inset separator for depth without forcing a light overlay */
|
|
67
|
-
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.04);
|
|
68
|
-
/* remove forced blur/white overlay which looks bad in dark themes */
|
|
69
|
-
backdrop-filter: none;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
.brand-title {
|
|
73
|
-
font-weight: 700;
|
|
74
|
-
font-size: 18px
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
.brand-sub {
|
|
78
|
-
font-size: 12px;
|
|
79
|
-
color: var(--hint)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
.header-actions {
|
|
83
|
-
display: flex;
|
|
84
|
-
gap: 8px;
|
|
85
|
-
align-items: center
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
.icon-btn {
|
|
89
|
-
background: transparent;
|
|
90
|
-
border: 1px solid var(--tg-theme-section-separator-color);
|
|
91
|
-
padding: 8px;
|
|
92
|
-
border-radius: 10px;
|
|
93
|
-
cursor: pointer;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
.chat-area {
|
|
97
|
-
flex: 1;
|
|
98
|
-
overflow: hidden;
|
|
99
|
-
display: flex;
|
|
100
|
-
flex-direction: column;
|
|
101
|
-
padding: 12px
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
.messages {
|
|
105
|
-
flex: 1;
|
|
106
|
-
overflow: auto;
|
|
107
|
-
display: flex;
|
|
108
|
-
flex-direction: column;
|
|
109
|
-
gap: 10px;
|
|
110
|
-
padding-bottom: 6px
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
.message {
|
|
114
|
-
max-width: 78%;
|
|
115
|
-
padding: 10px 14px;
|
|
116
|
-
border-radius: 14px;
|
|
117
|
-
line-height: 1.4;
|
|
118
|
-
white-space: pre-wrap
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
.message.user {
|
|
122
|
-
align-self: flex-end;
|
|
123
|
-
background: var(--accent);
|
|
124
|
-
color: white;
|
|
125
|
-
border-bottom-right-radius: 6px
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
.message.assistant {
|
|
129
|
-
align-self: flex-start;
|
|
130
|
-
background: var(--muted);
|
|
131
|
-
color: var(--text);
|
|
132
|
-
border-bottom-left-radius: 6px
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
.composer {
|
|
136
|
-
display: flex;
|
|
137
|
-
gap: 8px;
|
|
138
|
-
padding: 10px;
|
|
139
|
-
background: var(--tg-theme-secondary-bg-color);
|
|
140
|
-
border-top: 1px solid var(--tg-theme-section-separator-color);
|
|
141
|
-
align-items: center
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
.composer input[type="text"] {
|
|
145
|
-
flex: 1;
|
|
146
|
-
padding: 10px 12px;
|
|
147
|
-
border-radius: 18px;
|
|
148
|
-
border: 1px solid var(--tg-theme-section-separator-color);
|
|
149
|
-
background: var(--tg-theme-section-bg-color);
|
|
150
|
-
color: var(--text);
|
|
151
|
-
outline: none;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
.send-btn {
|
|
155
|
-
background: var(--accent);
|
|
156
|
-
color: #fff;
|
|
157
|
-
padding: 0;
|
|
158
|
-
border-radius: 50%;
|
|
159
|
-
border: none;
|
|
160
|
-
cursor: pointer;
|
|
161
|
-
min-width: 44px;
|
|
162
|
-
height: 44px;
|
|
163
|
-
display: flex;
|
|
164
|
-
align-items: center;
|
|
165
|
-
justify-content: center;
|
|
166
|
-
font-size: 18px
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
.search-panel {
|
|
170
|
-
padding: 8px 0
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
.search-panel.hidden {
|
|
174
|
-
display: none
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
.search-results {
|
|
178
|
-
list-style: none;
|
|
179
|
-
margin: 0;
|
|
180
|
-
padding: 0;
|
|
181
|
-
display: flex;
|
|
182
|
-
flex-direction: column;
|
|
183
|
-
gap: 8px
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
.search-item {
|
|
187
|
-
border-radius: 12px;
|
|
188
|
-
padding: 10px;
|
|
189
|
-
border: 1px solid #f1f5f9;
|
|
190
|
-
background: #fff
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
.search-item a {
|
|
194
|
-
font-weight: 600;
|
|
195
|
-
text-decoration: none;
|
|
196
|
-
color: var(--accent)
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
.search-item .snippet {
|
|
200
|
-
font-size: 13px;
|
|
201
|
-
color: var(--hint);
|
|
202
|
-
margin-top: 6px
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
.checkbox-inline {
|
|
206
|
-
display: flex;
|
|
207
|
-
align-items: center;
|
|
208
|
-
gap: 6px
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
.small {
|
|
212
|
-
font-size: 12px;
|
|
213
|
-
color: var(--hint)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
.app-footer {
|
|
217
|
-
text-align: center;
|
|
218
|
-
font-size: 12px;
|
|
219
|
-
color: var(--hint);
|
|
220
|
-
padding: 8px 6px;
|
|
221
|
-
/* footer border removed because footer text was removed */
|
|
222
|
-
border-top: none;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
.no-results {
|
|
226
|
-
color: var(--hint);
|
|
227
|
-
font-size: 13px;
|
|
228
|
-
padding: 10px
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
@media (max-width:520px) {
|
|
232
|
-
.app-shell {
|
|
233
|
-
height: 100vh
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
.brand-title {
|
|
237
|
-
font-size: 16px
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
.composer {
|
|
241
|
-
padding: 8px
|
|
242
|
-
}
|
|
243
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
require( "dotenv" ).config({ quiet: true });
|
|
2
|
-
|
|
3
|
-
const productionUrl = `https://${ process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
|
|
4
|
-
const webhookPath = "/api";
|
|
5
|
-
|
|
6
|
-
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
7
|
-
if ( !token )
|
|
8
|
-
{
|
|
9
|
-
throw new Error( "TELEGRAM_BOT_TOKEN is required" );
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const providers = [
|
|
13
|
-
{
|
|
14
|
-
name: "openrouter",
|
|
15
|
-
apiKey: process.env.OPENROUTER_API_KEY,
|
|
16
|
-
model: "z-ai/glm-4.5-air:free",
|
|
17
|
-
apiUrl: "https://openrouter.ai/api/v1",
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
name: "google",
|
|
21
|
-
apiKey: process.env.GOOGLE_API_KEY,
|
|
22
|
-
model: "gemini-2.5-pro",
|
|
23
|
-
apiUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
name: "google",
|
|
27
|
-
apiKey: process.env.GOOGLE_API_KEY,
|
|
28
|
-
model: "gemini-2.5-flash",
|
|
29
|
-
apiUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
name: "z.ai",
|
|
33
|
-
apiKey: process.env.ZAI_API_KEY,
|
|
34
|
-
model: "glm-4.5-flash",
|
|
35
|
-
apiUrl: "https://api.z.ai/api/paas/v4",
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
name: "qroq",
|
|
39
|
-
apiKey: process.env.QROQ_API_KEY,
|
|
40
|
-
model: "openai/gpt-oss-120b",
|
|
41
|
-
apiUrl: "https://api.groq.com/openai/v1",
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
name: "vercel",
|
|
45
|
-
apiKey: process.env.VERCEL_AI_GATEWAY_API_KEY,
|
|
46
|
-
model: "openai/gpt-oss-120b",
|
|
47
|
-
apiUrl: "https://ai-gateway.vercel.sh/v1",
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
name: "cerebras",
|
|
51
|
-
apiKey: process.env.CEREBRAS_API_KEY,
|
|
52
|
-
model: "gpt-oss-120b",
|
|
53
|
-
apiUrl: "https://api.cerebras.ai/v1",
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
name: "llm7",
|
|
57
|
-
apiKey: process.env.LLM7_API_KEY,
|
|
58
|
-
model: "gpt-o4-mini-2025-04-16",
|
|
59
|
-
apiUrl: "https://api.llm7.io/v1",
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
name: "cohere",
|
|
63
|
-
apiKey: process.env.COHERE_API_KEY,
|
|
64
|
-
model: "command-a-03-2025",
|
|
65
|
-
apiUrl: "https://api.cohere.ai/compatibility/v1",
|
|
66
|
-
},
|
|
67
|
-
];
|
|
68
|
-
|
|
69
|
-
module.exports = {
|
|
70
|
-
productionUrl,
|
|
71
|
-
webhookPath,
|
|
72
|
-
token,
|
|
73
|
-
providers
|
|
74
|
-
};
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
const { webhookPath, token, productionUrl } = require( "./config.js" );
|
|
2
|
-
const AIRouter = require( "../../main.js" );
|
|
3
|
-
const { providers } = require( "./config.js" );
|
|
4
|
-
|
|
5
|
-
class TelegramClient
|
|
6
|
-
{
|
|
7
|
-
constructor ()
|
|
8
|
-
{
|
|
9
|
-
this.token = token;
|
|
10
|
-
this.apiBaseUrl = `https://api.telegram.org/bot${token}`;
|
|
11
|
-
this.aiRouter = new AIRouter( providers );
|
|
12
|
-
this.chatHistories = {}; // In-memory store for chat histories
|
|
13
|
-
}
|
|
14
|
-
isNetworkError ( error )
|
|
15
|
-
{
|
|
16
|
-
// TODO: check with fetch
|
|
17
|
-
return error.message.includes( "socket hang up" ) ||
|
|
18
|
-
error.message.includes( "network socket disconnected" ) ||
|
|
19
|
-
error.message.includes( "fetch failed" );
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async sleep ( ms )
|
|
23
|
-
{
|
|
24
|
-
await new Promise( resolve => { return setTimeout( resolve, ms ) });
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async sendMessageWithRetry ( chatId, message, options = {})
|
|
28
|
-
{
|
|
29
|
-
return await this.withRetry( () =>
|
|
30
|
-
{
|
|
31
|
-
return this.makeRequest( "sendMessage", {
|
|
32
|
-
chat_id: chatId,
|
|
33
|
-
text: message,
|
|
34
|
-
...options,
|
|
35
|
-
});
|
|
36
|
-
}, options );
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async withRetry ( operation, options, retries = 10, delay = 50 )
|
|
40
|
-
{
|
|
41
|
-
console.log( `Starting operation with ${retries} retries` );
|
|
42
|
-
for ( let i = 0; i < retries; i++ )
|
|
43
|
-
{
|
|
44
|
-
try
|
|
45
|
-
{
|
|
46
|
-
return await operation();
|
|
47
|
-
}
|
|
48
|
-
catch ( error )
|
|
49
|
-
{
|
|
50
|
-
if ( this.isNetworkError( error ) )
|
|
51
|
-
{
|
|
52
|
-
console.log( `Retrying... Attempts left: ${retries - i - 1}`, error.cause, error.message );
|
|
53
|
-
await this.sleep( delay );
|
|
54
|
-
}
|
|
55
|
-
else
|
|
56
|
-
{
|
|
57
|
-
throw error;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
throw new Error( "Max retries reached" );
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async makeRequest ( method, params = {})
|
|
65
|
-
{
|
|
66
|
-
const url = `${this.apiBaseUrl}/${method}`;
|
|
67
|
-
const response = await fetch( url, {
|
|
68
|
-
method: "POST",
|
|
69
|
-
headers: {
|
|
70
|
-
"Content-Type": "application/json",
|
|
71
|
-
},
|
|
72
|
-
body: JSON.stringify( params ),
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
if ( !response.ok )
|
|
76
|
-
{
|
|
77
|
-
const error = await response.json();
|
|
78
|
-
throw new Error( `Telegram API error: ${JSON.stringify( error )}` );
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return await response.json();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async handleUpdate ( update )
|
|
85
|
-
{
|
|
86
|
-
if ( "message" in update && update.message.text )
|
|
87
|
-
{
|
|
88
|
-
const { text } = update.message;
|
|
89
|
-
const chatId = update.message.chat.id;
|
|
90
|
-
console.log( "text", text, "chatId", chatId );
|
|
91
|
-
// console.log( "chat history for ", chatId, this.chatHistories[chatId] );
|
|
92
|
-
|
|
93
|
-
// --- Command to launch the Mini App ---
|
|
94
|
-
if ( text === "/start" || text === "/app" )
|
|
95
|
-
{
|
|
96
|
-
const miniAppUrl = productionUrl; // Your Vercel deployment URL
|
|
97
|
-
|
|
98
|
-
const keyboard = {
|
|
99
|
-
inline_keyboard: [
|
|
100
|
-
[
|
|
101
|
-
{
|
|
102
|
-
text: "๐ Open AI Router App",
|
|
103
|
-
web_app: { url: miniAppUrl }
|
|
104
|
-
}
|
|
105
|
-
]
|
|
106
|
-
]
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
await this.sendMessageWithRetry( chatId, "Click the button below to open the AI Router Mini App!", {
|
|
110
|
-
reply_markup: JSON.stringify( keyboard )
|
|
111
|
-
});
|
|
112
|
-
return; // Stop processing further to avoid AI response
|
|
113
|
-
}
|
|
114
|
-
// --- END ---
|
|
115
|
-
|
|
116
|
-
if ( !this.chatHistories[chatId] )
|
|
117
|
-
{
|
|
118
|
-
this.chatHistories[chatId] = [];
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const history = this.chatHistories[chatId];
|
|
122
|
-
history.push({ role: "user", content: text });
|
|
123
|
-
if ( history.length > 100 )
|
|
124
|
-
{
|
|
125
|
-
this.chatHistories[chatId] = history.slice( history.length - 100 );
|
|
126
|
-
}
|
|
127
|
-
// console.log( "chat history for ", chatId, "after update", this.chatHistories[chatId] );
|
|
128
|
-
const systemPrompt = {
|
|
129
|
-
role: "system",
|
|
130
|
-
content: "You are a kind and selfless AI assistant. Be helpful and compassionate in your responses. Speak in user's language",
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
const messages = [
|
|
134
|
-
systemPrompt,
|
|
135
|
-
...this.chatHistories[chatId],
|
|
136
|
-
];
|
|
137
|
-
|
|
138
|
-
const response = await this.aiRouter.chatCompletion( messages );
|
|
139
|
-
history.push({ role: "assistant", content: response.content });
|
|
140
|
-
await this.sendMessageWithRetry( chatId, response.content );
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async registerWebhook ( productionUrl )
|
|
145
|
-
{
|
|
146
|
-
console.log( `Registering webhook with URL: ${productionUrl}${webhookPath}` );
|
|
147
|
-
const response = await this.makeRequest( "setWebhook", {
|
|
148
|
-
url: `${productionUrl}${webhookPath}`,
|
|
149
|
-
drop_pending_updates: true,
|
|
150
|
-
});
|
|
151
|
-
return { response, url: `${productionUrl}${webhookPath}` };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async unRegisterWebhook ()
|
|
155
|
-
{
|
|
156
|
-
const response = await this.makeRequest( "setWebhook", { url: "" });
|
|
157
|
-
return response.ok === true;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
module.exports = TelegramClient;
|
package/vercel.json
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 2,
|
|
3
|
-
"builds": [
|
|
4
|
-
{
|
|
5
|
-
"src": "vercel-project/api/index.js",
|
|
6
|
-
"use": "@vercel/node"
|
|
7
|
-
},
|
|
8
|
-
{
|
|
9
|
-
"src": "vercel-project/public/**",
|
|
10
|
-
"use": "@vercel/static"
|
|
11
|
-
}
|
|
12
|
-
],
|
|
13
|
-
"routes": [
|
|
14
|
-
{
|
|
15
|
-
"src": "/api",
|
|
16
|
-
"dest": "vercel-project/api/index.js"
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
"src": "/api/(.*)",
|
|
20
|
-
"dest": "vercel-project/api/index.js"
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"src": "/(.*)",
|
|
24
|
-
"dest": "vercel-project/public/$1"
|
|
25
|
-
}
|
|
26
|
-
]
|
|
27
|
-
}
|