freegate 0.6.16 → 0.6.18
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/README.md +11 -4
- package/README.ru.md +12 -4
- package/assets/dashboard-models.png +0 -0
- package/assets/dashboard.gif +0 -0
- package/assets/dashboard.png +0 -0
- package/bin/freegate.js +15 -0
- package/config.example.json +6 -0
- package/lib/contextstats.js +2 -2
- package/lib/dashboard.js +309 -128
- package/lib/methodology.js +23 -2
- package/lib/taskclassify.js +44 -3
- package/lib/websearch.js +75 -0
- package/package.json +1 -1
- package/providers.json +1 -1
- package/server.js +93 -14
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ is only yours — nobody else can connect to it, and you can't to theirs. Provid
|
|
|
29
29
|
keys live in your local `.env`, history in local files. **No third-party server
|
|
30
30
|
sees your keys or conversations.**
|
|
31
31
|
|
|
32
|
-

|
|
33
33
|
|
|
34
34
|
## Features
|
|
35
35
|
|
|
@@ -43,9 +43,10 @@ sees your keys or conversations.**
|
|
|
43
43
|
| 💰 **Free** | Free models only. The dashboard shows each provider's remaining limit. |
|
|
44
44
|
| ⚡ **Smart routing** | Picks the fastest, most stable provider for every request. |
|
|
45
45
|
| 🛡️ **Reliability** | Circuit breaker, request queue, auto-disable of dead providers, watchdog. |
|
|
46
|
-
| 📊 **Dashboard** | Status, speed, limits, history, tokens, RPM chart, savings ($). RU/EN. |
|
|
46
|
+
| 📊 **Dashboard** | Status, speed, limits, history, tokens, RPM chart, savings ($). RU/EN. **4 themes** (PolyCopy/Warm/Cosmic/Paper) — switcher in the header. |
|
|
47
47
|
| 💾 **Disk cache** | Repeat prompts don't consume limits at all. |
|
|
48
|
-
| 🎓 **Methodologist** | Agent answers like an engineer: plan→test→code (coding), stepwise (reasoning). Prompts customizable in `config.json`. |
|
|
48
|
+
| 🎓 **Methodologist** | Agent answers like an engineer: plan→test→code (coding), stepwise (reasoning), like a frontend designer (design). Prompts customizable in `config.json`. |
|
|
49
|
+
| 🌐 **Web search** | For question/answer it fetches current facts from the web (DuckDuckGo, keyless) — answers accurately instead of hallucinating. |
|
|
49
50
|
| 🔌 **Compatible** | Any OpenAI client: opencode, Cursor, your scripts. |
|
|
50
51
|
|
|
51
52
|

|
|
@@ -134,15 +135,21 @@ to watch for new releases. The model manager runs every 6 hours.
|
|
|
134
135
|
|
|
135
136
|
### Methodologist (Productive Agent Layer)
|
|
136
137
|
|
|
137
|
-
Freegate classifies the task (coding / reasoning / search / chat) and injects a
|
|
138
|
+
Freegate classifies the task (coding / design / reasoning / search / chat) and injects a
|
|
138
139
|
short system-prompt methodologist **without any client-side changes**. Any
|
|
139
140
|
OpenAI-compatible client (opencode, Cursor, chat) gets engineer-grade answers:
|
|
140
141
|
|
|
141
142
|
- **coding** — brief plan before code, a suggested test, where to verify.
|
|
143
|
+
- **design** — bold aesthetic idea, distinctive fonts, cohesive palette via CSS variables, choreographed motion, responsive. Inspired by Anthropic `frontend-design`.
|
|
142
144
|
- **reasoning** — reason stepwise, show assumptions.
|
|
143
145
|
- **search** — short factual answer, don't invent sources.
|
|
144
146
|
- **chat** — to the point, concise.
|
|
145
147
|
|
|
148
|
+
A shared **"don't give up"** block is prepended to every category: if the model
|
|
149
|
+
can't find a tool/file/skill where it expected one, it doesn't refuse — it looks
|
|
150
|
+
for a workaround (reads files directly, checks node_modules, tries alternatives).
|
|
151
|
+
This makes free models act resourcefully instead of ending with "I can't".
|
|
152
|
+
|
|
146
153
|
The task category also nudges routing toward matching provider categories
|
|
147
154
|
(`coding`→coding models, `reasoning`→reasoning models) without dropping fallback.
|
|
148
155
|
Category distribution is visible on the dashboard and via
|
package/README.ru.md
CHANGED
|
@@ -28,7 +28,7 @@ Freegate крутится **на твоей машине** (`http://localhost:40
|
|
|
28
28
|
провайдеров лежат в твоём локальном `.env`, история — в локальных файлах. **Ни
|
|
29
29
|
один чужой сервер не видит твои ключи и переписки.**
|
|
30
30
|
|
|
31
|
-

|
|
32
32
|
|
|
33
33
|
## Возможности
|
|
34
34
|
|
|
@@ -42,9 +42,10 @@ Freegate крутится **на твоей машине** (`http://localhost:40
|
|
|
42
42
|
| 💰 **Бесплатно** | Только free-модели. Дашборд показывает остаток лимита каждого провайдера. |
|
|
43
43
|
| ⚡ **Умный выбор** | Прокси сам находит самый быстрый и стабильный провайдер для каждого запроса. |
|
|
44
44
|
| 🛡️ **Надёжность** | Circuit breaker, очередь запросов, автоотключение мёртвых провайдеров, watchdog. |
|
|
45
|
-
| 📊 **Дашборд** | Статус, скорость, лимиты, история, токены, RPM-график, экономия ($). RU/EN. |
|
|
45
|
+
| 📊 **Дашборд** | Статус, скорость, лимиты, история, токены, RPM-график, экономия ($). RU/EN. **4 темы** (PolyCopy/Тёплый/Космос/Бумага) — переключатель в шапке. |
|
|
46
46
|
| 💾 **Кэш на диске** | Повторные промпты не тратят лимиты вообще. |
|
|
47
|
-
| 🎓 **Методолог** | Агент отвечает как инженер: план→тест→код (coding), пошагово (reasoning). Промпты настраиваются в `config.json`. |
|
|
47
|
+
| 🎓 **Методолог** | Агент отвечает как инженер: план→тест→код (coding), пошагово (reasoning), **как фронтенд-дизайнер** (design). Промпты настраиваются в `config.json`. |
|
|
48
|
+
| 🌐 **Веб-поиск** | Для вопросов-поиска Freegate находит актуальные факты в интернете (DuckDuckGo, без ключа) — отвечает по существу, а не галлюцинирует. |
|
|
48
49
|
| 🔌 **Совместимость** | Любой OpenAI-клиент: opencode, Cursor, ChatGPT-аналоги, твои скрипты. |
|
|
49
50
|
|
|
50
51
|

|
|
@@ -63,7 +64,7 @@ npx freegate test # проверить, что всё работает
|
|
|
63
64
|
|
|
64
65
|
**Подключить к Cursor в 2 клика:** Cursor → Settings → Models → «OpenAI-compatible» → Base URL `http://localhost:4000/v1`, API Key = твой пароль.
|
|
65
66
|
|
|
66
|
-
Дашборд: `http://localhost:4000/?key=твой_
|
|
67
|
+
Дашборд: `http://localhost:4000/?key=твой_пароль`. Темы: `?theme=cosmic` или кнопка «Тема» в шапке; `npx freegate themes` — список.
|
|
67
68
|
|
|
68
69
|
Или через Docker:
|
|
69
70
|
|
|
@@ -139,10 +140,17 @@ Freegate определяет тип задачи (кодинг / рассужд
|
|
|
139
140
|
ответы как от опытного инженера:
|
|
140
141
|
|
|
141
142
|
- **coding** — краткий план перед кодом, предложенный тест, где проверять.
|
|
143
|
+
- **design** — смелая эстетическая идея, характерные шрифты, единая палитра через CSS-переменные, срежиссированные анимации, адаптив. По мотивам Anthropic `frontend-design`.
|
|
142
144
|
- **reasoning** — рассуждать пошагово, показывать допущения.
|
|
143
145
|
- **search** — короткий фактологичный ответ, не выдумывать источник.
|
|
144
146
|
- **chat** — по существу и кратко.
|
|
145
147
|
|
|
148
|
+
Ко всем категориям подмешивается общий блок **«не сдавайся»**: если модель не
|
|
149
|
+
находит нужный инструмент/файл/скилл там, где ожидал — не отказывается, а ищет
|
|
150
|
+
обходной путь (читает файлы напрямую, проверяет node_modules, пробует
|
|
151
|
+
альтернативы). Вследствие этого free-модели ведут себя находчивее, а не
|
|
152
|
+
заканчивают ответ «не могу».
|
|
153
|
+
|
|
146
154
|
Дополнительно: категория задачи даёт буст моделям подходящей категории
|
|
147
155
|
(`coding`→coding-модели, `reasoning`→reasoning-модели), не исключая fallback.
|
|
148
156
|
Распределение категорий видно в дашборде и через
|
|
Binary file
|
|
Binary file
|
package/assets/dashboard.png
CHANGED
|
Binary file
|
package/bin/freegate.js
CHANGED
|
@@ -185,6 +185,21 @@ WantedBy=default.target
|
|
|
185
185
|
}
|
|
186
186
|
} else if (cmd === 'dashboard') {
|
|
187
187
|
console.log('Открой http://localhost:4000/ (запусти start сначала)');
|
|
188
|
+
} else if (cmd === 'themes') {
|
|
189
|
+
console.log('🎨 Темы дашборда Freegate');
|
|
190
|
+
console.log('-----------------------------');
|
|
191
|
+
const themes = [
|
|
192
|
+
['poly', 'PolyCopy — тёмный, фиолетовый акцент (по умолчанию)'],
|
|
193
|
+
['warm', 'Тёплый — янтарный, уютный ламповый'],
|
|
194
|
+
['cosmic', 'Космос — глубокий синий, мягкий голубой'],
|
|
195
|
+
['paper', 'Бумага — светлый, инженерный чертёж'],
|
|
196
|
+
];
|
|
197
|
+
for (const [id, desc] of themes) console.log(' ' + id.padEnd(8) + desc);
|
|
198
|
+
console.log('-----------------------------');
|
|
199
|
+
console.log('Как включить:');
|
|
200
|
+
console.log(' ?theme=cosmic — открыть дашборд в теме вручную');
|
|
201
|
+
console.log(' Кнопка «Тема» в шапке дашборда — переключать по кругу');
|
|
202
|
+
console.log(' Памятка: `--theme` не нужен, тема хранится в localStorage браузера.');
|
|
188
203
|
} else if (cmd === 'test') {
|
|
189
204
|
const http = require('http');
|
|
190
205
|
const base = `http://127.0.0.1:${process.env.PORT || 4000}`;
|
package/config.example.json
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
"reasoning": "Рассуждай пошагово, показывай допущения, затем вывод."
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
|
+
"websearch": {
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"limit": 5,
|
|
22
|
+
"timeout": 8000,
|
|
23
|
+
"queryMinChars": 6
|
|
24
|
+
},
|
|
19
25
|
"providers": {
|
|
20
26
|
"or-nemotron-550b": {
|
|
21
27
|
"endpoint": "https://openrouter.ai/api/v1/chat/completions",
|
package/lib/contextstats.js
CHANGED
|
@@ -40,7 +40,7 @@ function emptyBucket(hour, provider) {
|
|
|
40
40
|
status429: 0,
|
|
41
41
|
statusOther: 0,
|
|
42
42
|
sysShareSum: 0,
|
|
43
|
-
tasks: { coding: 0, reasoning: 0, search: 0, chat: 0 },
|
|
43
|
+
tasks: { coding: 0, reasoning: 0, search: 0, chat: 0, design: 0 },
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -163,7 +163,7 @@ class ContextStats {
|
|
|
163
163
|
let sysShareRequests = 0;
|
|
164
164
|
const providers = {};
|
|
165
165
|
let taskTotal = 0;
|
|
166
|
-
const tasks = { coding: 0, reasoning: 0, search: 0, chat: 0 };
|
|
166
|
+
const tasks = { coding: 0, reasoning: 0, search: 0, chat: 0, design: 0 };
|
|
167
167
|
|
|
168
168
|
for (const b of this.buckets.values()) {
|
|
169
169
|
if (b.ts < cutoff) continue;
|
package/lib/dashboard.js
CHANGED
|
@@ -8,166 +8,296 @@ const HTML = `<!DOCTYPE html>
|
|
|
8
8
|
<head>
|
|
9
9
|
<meta charset="UTF-8">
|
|
10
10
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
11
|
-
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%
|
|
11
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%23e8b553'/%3E%3C/svg%3E">
|
|
12
12
|
<title>Freegate — Панель управления</title>
|
|
13
|
-
|
|
13
|
+
<style>
|
|
14
|
+
/* ── Aesthetic: современный тёмный админ-дашборд (в стиле PolyCopy) ── */
|
|
14
15
|
:root {
|
|
15
|
-
--bg: #
|
|
16
|
-
--accent: #
|
|
17
|
-
--
|
|
16
|
+
--bg: #0d0d0f; --panel: #141416; --panel2: #1b1b1e; --border: #26262a;
|
|
17
|
+
--accent: #7c5cff; --accent-dim: #9d8bff; --accent-ink: #c9beff;
|
|
18
|
+
--ok: #4ade80; --warn: #fbbf24; --err: #f87171;
|
|
19
|
+
--text: #ececef; --muted: #9a9aa2;
|
|
20
|
+
--radius: 12px; --radius-sm: 8px;
|
|
21
|
+
/* тоновые полупрозрачные акценты (для мульти-тем) */
|
|
22
|
+
--t-log: rgba(124,92,255,.18); --t-glow: rgba(124,92,255,.2);
|
|
23
|
+
--t-row: rgba(124,92,255,.03); --t-chip: rgba(124,92,255,.16);
|
|
24
|
+
--t-bg-ok: rgba(74,222,128,.12); --t-bd-ok: rgba(74,222,128,.35); --t-dot-ok: rgba(74,222,128,.18);
|
|
25
|
+
--t-bg-warn: rgba(251,191,36,.12); --t-bd-warn: rgba(251,191,36,.4);
|
|
26
|
+
--t-bg-err: rgba(248,113,113,.12); --t-bd-err: rgba(248,113,113,.4);
|
|
27
|
+
--t-bg-muted: rgba(154,154,162,.14); --t-bg-muted2: rgba(154,154,162,.1);
|
|
28
|
+
}
|
|
29
|
+
body[data-theme="warm"] {
|
|
30
|
+
--bg: #131006; --panel: #1a1510; --panel2: #211c13; --border: #372e1c;
|
|
31
|
+
--accent: #e8b553; --accent-dim: #c8983f; --accent-ink: #ffd58a;
|
|
32
|
+
--ok: #8fbf6a; --warn: #e0a449; --err: #d97b6c;
|
|
33
|
+
--text: #e8dcc0; --muted: #8a7d5f;
|
|
34
|
+
--t-log: rgba(232,181,83,.18); --t-glow: rgba(232,181,83,.2);
|
|
35
|
+
--t-row: rgba(232,181,83,.03); --t-chip: rgba(232,181,83,.16);
|
|
36
|
+
--t-bg-ok: rgba(143,191,106,.12); --t-bd-ok: rgba(143,191,106,.35); --t-dot-ok: rgba(143,191,106,.18);
|
|
37
|
+
--t-bg-warn: rgba(224,164,73,.12); --t-bd-warn: rgba(224,164,73,.4);
|
|
38
|
+
--t-bg-err: rgba(217,123,108,.12); --t-bd-err: rgba(217,123,108,.4);
|
|
39
|
+
--t-bg-muted: rgba(154,143,117,.14); --t-bg-muted2: rgba(154,143,117,.1);
|
|
40
|
+
}
|
|
41
|
+
body[data-theme="cosmic"] {
|
|
42
|
+
--bg: #0a1018; --panel: #0f1720; --panel2: #141e29; --border: #1f2d3c;
|
|
43
|
+
--accent: #6fb6e0; --accent-dim: #4f8fb5; --accent-ink: #a9d8f5;
|
|
44
|
+
--ok: #6fbfa0; --warn: #d9b06a; --err: #d97b7b;
|
|
45
|
+
--text: #d7e2e8; --muted: #5d6d7d;
|
|
46
|
+
--t-log: rgba(111,182,224,.18); --t-glow: rgba(111,182,224,.2);
|
|
47
|
+
--t-row: rgba(111,182,224,.03); --t-chip: rgba(111,182,224,.16);
|
|
48
|
+
--t-bg-ok: rgba(111,191,160,.12); --t-bd-ok: rgba(111,191,160,.35); --t-dot-ok: rgba(111,191,160,.18);
|
|
49
|
+
--t-bg-warn: rgba(217,176,106,.12); --t-bd-warn: rgba(217,176,106,.4);
|
|
50
|
+
--t-bg-err: rgba(217,123,123,.12); --t-bd-err: rgba(217,123,123,.4);
|
|
51
|
+
--t-bg-muted: rgba(122,134,147,.14); --t-bg-muted2: rgba(122,134,147,.1);
|
|
52
|
+
}
|
|
53
|
+
body[data-theme="paper"] {
|
|
54
|
+
--bg: #f4f5f7; --panel: #ffffff; --panel2: #eef1f5; --border: #d7dce3;
|
|
55
|
+
--accent: #1f3a5f; --accent-dim: #2c5282; --accent-ink: #1f3a5f;
|
|
56
|
+
--ok: #2e7d52; --warn: #b26a00; --err: #c0392b;
|
|
57
|
+
--text: #1c2733; --muted: #6b7683;
|
|
58
|
+
--t-log: rgba(31,58,95,.18); --t-glow: rgba(31,58,95,.2);
|
|
59
|
+
--t-row: rgba(31,58,95,.03); --t-chip: rgba(31,58,95,.12);
|
|
60
|
+
--t-bg-ok: rgba(46,125,82,.1); --t-bd-ok: rgba(46,125,82,.35); --t-dot-ok: rgba(46,125,82,.18);
|
|
61
|
+
--t-bg-warn: rgba(178,106,0,.1); --t-bd-warn: rgba(178,106,0,.4);
|
|
62
|
+
--t-bg-err: rgba(192,57,43,.1); --t-bd-err: rgba(192,57,43,.4);
|
|
63
|
+
--t-bg-muted: rgba(107,118,131,.14); --t-bg-muted2: rgba(107,118,131,.1);
|
|
18
64
|
}
|
|
19
65
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
20
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
.
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
.
|
|
34
|
-
|
|
35
|
-
/*
|
|
36
|
-
#tabs { display: flex;
|
|
37
|
-
.tab { padding:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.tab
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
67
|
+
background: var(--bg); color: var(--text); padding-bottom: 40px;
|
|
68
|
+
-webkit-font-smoothing: antialiased; }
|
|
69
|
+
a { color: inherit; text-decoration: none; }
|
|
70
|
+
|
|
71
|
+
/* ── Раскладка: сайдбар + контент ── */
|
|
72
|
+
.layout { display: flex; min-height: 100vh; }
|
|
73
|
+
#sidebar { width: 240px; flex-shrink: 0; background: var(--panel); border-right: 1px solid var(--border);
|
|
74
|
+
position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; padding: 20px 14px; }
|
|
75
|
+
.side-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 26px; padding: 0 6px; }
|
|
76
|
+
.side-logo { width: 26px; height: 26px; border-radius: 7px; background: var(--accent); display: grid; place-items: center;
|
|
77
|
+
color: #fff; font-weight: 800; font-size: 14px; box-shadow: 0 0 0 3px var(--t-log); }
|
|
78
|
+
.side-brand .name { font-weight: 700; font-size: 16px; letter-spacing: .2px; }
|
|
79
|
+
.side-brand .name span { color: var(--muted); font-weight: 600; }
|
|
80
|
+
|
|
81
|
+
/* Сайдбар-навигация */
|
|
82
|
+
#tabs { display: flex; flex-direction: column; gap: 3px; }
|
|
83
|
+
.tab { padding: 10px 12px; border-radius: var(--radius-sm); cursor: pointer; color: var(--muted);
|
|
84
|
+
font-size: 13.5px; font-weight: 500; user-select: none; transition: all .15s ease; display: flex; align-items: center; gap: 10px;
|
|
85
|
+
border: none; background: none; text-align: left; position: relative; }
|
|
86
|
+
.tab::before { content: ''; width: 7px; height: 7px; border-radius: 2px; background: currentColor; opacity: .6; flex-shrink: 0; }
|
|
87
|
+
.tab:hover { color: var(--text); background: var(--panel2); }
|
|
88
|
+
.tab.active { color: var(--accent-ink); background: var(--panel2); }
|
|
89
|
+
.tab.active::before { background: var(--accent); opacity: 1; box-shadow: 0 0 0 3px var(--t-glow); }
|
|
90
|
+
|
|
91
|
+
.side-foot { margin-top: auto; padding: 12px 6px 2px; border-top: 1px solid var(--border); }
|
|
92
|
+
.side-user { display: flex; align-items: center; gap: 10px; padding: 8px 6px; }
|
|
93
|
+
.side-avatar { width: 30px; height: 30px; border-radius: 8px; background: var(--panel2); display: grid; place-items: center;
|
|
94
|
+
color: var(--accent-dim); font-weight: 700; font-size: 13px; border: 1px solid var(--border); }
|
|
95
|
+
.side-user .info .role { font-size: 11px; color: var(--muted); }
|
|
96
|
+
.side-user .info .email { font-size: 12.5px; font-weight: 600; }
|
|
97
|
+
|
|
98
|
+
/* ── Контент ── */
|
|
99
|
+
#main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
100
|
+
#topbar { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(10px);
|
|
101
|
+
border-bottom: 1px solid var(--border); padding: 14px 28px; display: flex; align-items: center; gap: 14px; }
|
|
102
|
+
.brand { font-size: 15px; font-weight: 700; color: var(--text); display: flex; align-items: center; gap: 9px; }
|
|
103
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: var(--muted); }
|
|
104
|
+
.dot.ok { background: var(--ok); box-shadow: 0 0 0 3px var(--t-dot-ok); }
|
|
105
|
+
.dot.bad { background: var(--err); box-shadow: 0 0 0 3px var(--t-bd-err); }
|
|
106
|
+
|
|
107
|
+
.lang-switch { margin-left: auto; display: flex; gap: 4px; background: var(--panel); border: 1px solid var(--border);
|
|
108
|
+
border-radius: 999px; padding: 3px; }
|
|
109
|
+
.lang-btn { padding: 5px 14px; border-radius: 999px; background: transparent; border: none;
|
|
110
|
+
color: var(--muted); font-size: 12px; font-weight: 600; cursor: pointer; transition: all .15s ease; }
|
|
111
|
+
.lang-btn.active { color: #fff; background: var(--accent); }
|
|
112
|
+
.lang-btn:hover:not(.active) { color: var(--text); }
|
|
113
|
+
|
|
114
|
+
/* ── Переключатель темы ── */
|
|
115
|
+
.theme-switch { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border: 1px solid var(--border);
|
|
116
|
+
background: var(--panel); border-radius: 999px; }
|
|
117
|
+
.theme-swatch { width: 14px; height: 14px; border-radius: 4px; }
|
|
118
|
+
.theme-btn { padding: 4px 14px; border-radius: 999px; background: transparent; border: none;
|
|
119
|
+
color: var(--muted); font-size: 12px; font-weight: 600; cursor: pointer; transition: all .15s ease; }
|
|
120
|
+
.theme-btn:hover { color: var(--text); }
|
|
121
|
+
|
|
122
|
+
/* ── Главный заголовок ── */
|
|
123
|
+
#page-head { padding: 26px 28px 0; }
|
|
124
|
+
#page-head .crumb { color: var(--muted); font-size: 12px; margin-bottom: 4px; }
|
|
125
|
+
#page-head h1 { font-size: 20px; font-weight: 700; }
|
|
126
|
+
#page-head .sub { color: var(--muted); font-size: 13px; margin-top: 3px; }
|
|
127
|
+
|
|
128
|
+
/* ── KPI-карточки (крупные метрики) ── */
|
|
129
|
+
#kpiBar { padding: 20px 28px 4px; display: grid !important; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; }
|
|
130
|
+
.kpi-chip { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 16px 13px; }
|
|
131
|
+
.kpi-label { display: block; color: var(--muted); font-size: 12px; font-weight: 500; }
|
|
132
|
+
.kpi-chip b { display: block; color: var(--text); font-size: 24px; font-weight: 700; margin-top: 8px; letter-spacing: -.5px; }
|
|
133
|
+
.kpi-chip.warn b { color: var(--warn); } .kpi-chip.err b { color: var(--err); }
|
|
134
|
+
.kpi-chip.warn, .kpi-chip.err { border-color: var(--t-bd-warn); }
|
|
135
|
+
.kpi-chip.err { border-color: var(--t-bd-err); }
|
|
136
|
+
|
|
137
|
+
/* ── Панели: общий перенос заголовка KPI полоски стал частью content ── */
|
|
138
|
+
main { padding: 0 28px; }
|
|
43
139
|
.panel { display: none; padding-top: 16px; }
|
|
44
140
|
.panel.active { display: block; }
|
|
45
141
|
|
|
46
|
-
/* ── Переключатель языка ── */
|
|
47
|
-
.lang-switch { margin-left: auto; display: flex; gap: 4px; }
|
|
48
|
-
.lang-btn { padding: 5px 11px; border-radius: 6px; background: var(--panel); border: 1px solid var(--border);
|
|
49
|
-
color: var(--muted); font-size: 11px; font-weight: bold; cursor: pointer; }
|
|
50
|
-
.lang-btn.active { color: var(--accent); border-color: var(--accent); }
|
|
51
|
-
.lang-btn:hover { color: var(--text); }
|
|
52
|
-
|
|
53
142
|
/* ── Карточки/сетки ── */
|
|
54
|
-
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(
|
|
55
|
-
.card { background: var(--panel); border-radius:
|
|
56
|
-
.card h2 { color: var(--
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
.stat
|
|
143
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
|
144
|
+
.card { background: var(--panel); border-radius: var(--radius); padding: 18px 20px; border: 1px solid var(--border); }
|
|
145
|
+
.card h2 { color: var(--muted); font-size: 11px; margin-bottom: 14px; text-transform: uppercase; letter-spacing: .8px;
|
|
146
|
+
display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
|
147
|
+
.card h2::before { content: ''; width: 4px; height: 4px; border-radius: 50%; background: var(--accent); }
|
|
148
|
+
.stat { display: flex; justify-content: space-between; margin-bottom: 8px; gap: 10px; }
|
|
149
|
+
.stat-label { color: var(--muted); font-size: 13px; }
|
|
150
|
+
.stat-value { color: var(--text); font-weight: 600; text-align: right; font-size: 13px; }
|
|
60
151
|
|
|
61
152
|
/* ── Алерты ── */
|
|
62
153
|
#alerts { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
|
63
|
-
.alert { padding:
|
|
64
|
-
.alert.err {
|
|
65
|
-
.alert.warn {
|
|
66
|
-
.alert.ok {
|
|
154
|
+
.alert { padding: 11px 16px; border-radius: var(--radius-sm); font-size: 13px; border: 1px solid; background: var(--panel); }
|
|
155
|
+
.alert.err { border-color: var(--t-bd-err); color: #fbc4c4; }
|
|
156
|
+
.alert.warn { border-color: var(--t-bd-warn); color: #fde9b8; }
|
|
157
|
+
.alert.ok { border-color: var(--t-bd-ok); color: #b7efcd; }
|
|
67
158
|
|
|
68
159
|
/* ── Тулбар: поиск + фильтры ── */
|
|
69
160
|
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; }
|
|
70
|
-
.search { flex: 1; min-width:
|
|
71
|
-
border: 1px solid var(--border); background: var(--panel); color:
|
|
161
|
+
.search { flex: 1; min-width: 180px; max-width: 320px; padding: 10px 14px; border-radius: var(--radius-sm);
|
|
162
|
+
border: 1px solid var(--border); background: var(--panel); color: var(--text); font-size: 13px; outline: none;
|
|
163
|
+
transition: border-color .15s ease; }
|
|
72
164
|
.search:focus { border-color: var(--accent); }
|
|
73
|
-
select.filter { padding:
|
|
74
|
-
background: var(--panel); color: var(--text); font-size:
|
|
165
|
+
select.filter { padding: 10px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border);
|
|
166
|
+
background: var(--panel); color: var(--text); font-size: 13px; outline: none; cursor: pointer; }
|
|
75
167
|
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
76
|
-
.chip { padding:
|
|
77
|
-
color: var(--muted); font-size:
|
|
78
|
-
.chip.active { color: var(--accent);
|
|
168
|
+
.chip { padding: 8px 14px; border-radius: 999px; background: var(--panel); border: 1px solid var(--border);
|
|
169
|
+
color: var(--muted); font-size: 12.5px; cursor: pointer; user-select: none; transition: all .15s ease; }
|
|
170
|
+
.chip.active { color: #fff; border-color: var(--accent); background: var(--t-chip); }
|
|
171
|
+
.chip:hover { color: var(--text); }
|
|
79
172
|
.count-hint { color: var(--muted); font-size: 12px; margin-left: auto; }
|
|
80
173
|
|
|
81
174
|
/* ── Таблицы ── */
|
|
82
|
-
.table-wrap { overflow-x: auto; background: var(--panel); border-radius:
|
|
175
|
+
.table-wrap { overflow-x: auto; background: var(--panel); border-radius: var(--radius); border: 1px solid var(--border); }
|
|
83
176
|
table { width: 100%; border-collapse: collapse; min-width: 640px; }
|
|
84
|
-
th, td { text-align: left; padding:
|
|
85
|
-
th { color: var(--
|
|
86
|
-
th:hover { color:
|
|
87
|
-
th .arr { font-size: 9px; margin-left:
|
|
177
|
+
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; white-space: nowrap; }
|
|
178
|
+
th { color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: .6px; cursor: pointer; user-select: none; font-weight: 600; }
|
|
179
|
+
th:hover { color: var(--text); }
|
|
180
|
+
th .arr { font-size: 9px; margin-left: 4px; }
|
|
88
181
|
td { color: var(--text); }
|
|
89
|
-
tr:
|
|
90
|
-
|
|
91
|
-
.badge
|
|
92
|
-
.badge.
|
|
93
|
-
.badge.
|
|
94
|
-
.badge.
|
|
95
|
-
.badge.
|
|
96
|
-
.
|
|
182
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
183
|
+
tr:hover td { background: var(--t-row); }
|
|
184
|
+
.badge { padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; display: inline-block; }
|
|
185
|
+
.badge.up, .badge.active { background: var(--t-bg-ok); color: var(--ok); }
|
|
186
|
+
.badge.ratelimited { background: var(--t-bg-warn); color: var(--warn); }
|
|
187
|
+
.badge.error, .badge.dead { background: var(--t-bg-err); color: var(--err); }
|
|
188
|
+
.badge.unknown, .badge.untested { background: var(--t-bg-muted); color: var(--muted); }
|
|
189
|
+
.badge.disabled, .badge.user-disabled { background: var(--t-bg-muted2); color: var(--muted); }
|
|
190
|
+
.limit-bar { width: 110px; height: 6px; background: var(--panel2); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-right: 8px; }
|
|
97
191
|
.limit-fill { height: 100%; border-radius: 4px; }
|
|
98
|
-
.score { font-weight:
|
|
99
|
-
.mono { font-family: ui-monospace, Menlo, monospace; font-size:
|
|
192
|
+
.score { font-weight: 700; color: var(--text); }
|
|
193
|
+
.mono { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; }
|
|
100
194
|
|
|
101
195
|
/* ── Действия над моделями ── */
|
|
102
196
|
.model-actions { display: flex; gap: 6px; align-items: center; white-space: nowrap; }
|
|
103
|
-
.mini-btn { padding:
|
|
104
|
-
color: var(--text); font-size:
|
|
105
|
-
.mini-btn:hover { border-color: var(--accent); color: var(--accent); }
|
|
197
|
+
.mini-btn { padding: 6px 11px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--panel2);
|
|
198
|
+
color: var(--text); font-size: 12px; cursor: pointer; transition: all .15s ease; font-weight: 500; }
|
|
199
|
+
.mini-btn:hover { border-color: var(--accent); color: var(--accent-ink); }
|
|
106
200
|
.mini-btn.off { color: var(--warn); }
|
|
107
201
|
|
|
108
202
|
/* ── Рекомендации ── */
|
|
109
|
-
.rec { font-size:
|
|
110
|
-
.rec-badge { padding:
|
|
111
|
-
.rec-badge.ok { background:
|
|
112
|
-
.rec-badge.warn { background:
|
|
113
|
-
.rec-badge { background:
|
|
203
|
+
.rec { font-size: 12.5px; margin-bottom: 7px; line-height: 1.6; }
|
|
204
|
+
.rec-badge { padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; margin-right: 6px; }
|
|
205
|
+
.rec-badge.ok { background: var(--t-bg-ok); color: var(--ok); }
|
|
206
|
+
.rec-badge.warn { background: var(--t-bg-warn); color: var(--warn); }
|
|
207
|
+
.rec-badge { background: var(--t-bg-muted); color: var(--muted); }
|
|
114
208
|
|
|
115
209
|
/* ── Экономия ── */
|
|
116
|
-
.savings .savings-usd { color: var(--ok); font-size:
|
|
210
|
+
.savings .savings-usd { color: var(--ok); font-size: 28px; font-weight: 700; letter-spacing: -.5px; }
|
|
117
211
|
|
|
118
|
-
canvas { width: 100%; height:
|
|
119
|
-
.refresh { color: var(--muted); font-size:
|
|
120
|
-
.task-bar { display: flex; align-items: center; gap: 10px; margin-bottom:
|
|
121
|
-
.task-bar .name { width: 90px; color: var(--muted); }
|
|
212
|
+
canvas { width: 100%; height: 180px; background: var(--panel); border-radius: var(--radius); border: 1px solid var(--border); }
|
|
213
|
+
.refresh { color: var(--muted); font-size: 12px; margin-top: 14px; }
|
|
214
|
+
.task-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 13px; }
|
|
215
|
+
.task-bar .name { width: 90px; color: var(--muted); text-transform: capitalize; }
|
|
122
216
|
.task-bar .bar { flex: 1; height: 8px; background: var(--panel2); border-radius: 5px; overflow: hidden; }
|
|
123
|
-
.task-bar .fill { height: 100%;
|
|
124
|
-
.task-bar .num { width:
|
|
217
|
+
.task-bar .fill { height: 100%; border-radius: 5px; }
|
|
218
|
+
.task-bar .num { width: 84px; text-align: right; color: var(--muted); }
|
|
125
219
|
|
|
126
220
|
/* ── Setup ── */
|
|
127
221
|
.setup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; }
|
|
128
|
-
.key-card { background: var(--panel); border-radius:
|
|
129
|
-
.key-card h3 { color: var(--
|
|
130
|
-
.key-card .desc { color: var(--muted); font-size:
|
|
131
|
-
.key-card ol { color:
|
|
222
|
+
.key-card { background: var(--panel); border-radius: var(--radius); padding: 18px 20px; border: 1px solid var(--border); }
|
|
223
|
+
.key-card h3 { color: var(--text); font-size: 15px; margin-bottom: 6px; font-weight: 700; }
|
|
224
|
+
.key-card .desc { color: var(--muted); font-size: 12.5px; margin-bottom: 10px; line-height: 1.5; }
|
|
225
|
+
.key-card ol { color: var(--muted); font-size: 12px; padding-left: 18px; margin-bottom: 10px; }
|
|
132
226
|
.key-card li { margin-bottom: 4px; }
|
|
133
|
-
.key-card a { color: var(--accent);
|
|
227
|
+
.key-card a { color: var(--accent-dim); }
|
|
134
228
|
.key-input-row { display: flex; gap: 8px; align-items: center; }
|
|
135
|
-
.key-input { flex: 1; padding:
|
|
136
|
-
.btn { padding:
|
|
137
|
-
.btn-validate { background: var(--panel2); color: var(--
|
|
138
|
-
.btn-
|
|
139
|
-
.btn-
|
|
140
|
-
.
|
|
141
|
-
.
|
|
142
|
-
.status-
|
|
229
|
+
.key-input { flex: 1; padding: 10px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--bg); color: var(--text); font-size: 13px; font-family: monospace; }
|
|
230
|
+
.btn { padding: 10px 16px; border-radius: var(--radius-sm); border: none; cursor: pointer; font-size: 13px; font-weight: 600; transition: all .15s ease; }
|
|
231
|
+
.btn-validate { background: var(--panel2); color: var(--text); border: 1px solid var(--border); }
|
|
232
|
+
.btn-validate:hover { border-color: var(--accent); color: var(--accent-ink); }
|
|
233
|
+
.btn-save { background: var(--accent); color: #fff; }
|
|
234
|
+
.btn-save:hover { filter: brightness(1.08); }
|
|
235
|
+
.btn-test-all { background: var(--panel2); color: var(--ok); border: 1px solid var(--border); }
|
|
236
|
+
.status-ok { color: var(--ok); font-size: 12px; }
|
|
237
|
+
.status-err { color: var(--err); font-size: 12px; }
|
|
238
|
+
.status-wait { color: var(--muted); font-size: 12px; }
|
|
143
239
|
.hidden { display: none; }
|
|
144
240
|
|
|
241
|
+
@media (max-width: 900px) {
|
|
242
|
+
#sidebar { width: 64px; padding: 20px 8px; }
|
|
243
|
+
.side-brand .name, .tab .label, .side-user .info { display: none; }
|
|
244
|
+
.tab { justify-content: center; }
|
|
245
|
+
.side-user { justify-content: center; }
|
|
246
|
+
}
|
|
145
247
|
@media (max-width: 640px) {
|
|
146
|
-
#topbar { padding:
|
|
147
|
-
|
|
148
|
-
.kpi-chip { font-size: 11px; padding: 5px 9px; }
|
|
248
|
+
#topbar, #page-head, main, #kpiBar { padding-left: 16px; padding-right: 16px; }
|
|
249
|
+
.kpi-chip b { font-size: 20px; }
|
|
149
250
|
}
|
|
150
251
|
</style>
|
|
151
252
|
</head>
|
|
152
253
|
<body>
|
|
153
|
-
<
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
</div>
|
|
160
|
-
</header>
|
|
254
|
+
<div class="layout">
|
|
255
|
+
<aside id="sidebar">
|
|
256
|
+
<div class="side-brand">
|
|
257
|
+
<div class="side-logo">F</div>
|
|
258
|
+
<div class="name">free<strong>gate</strong></div>
|
|
259
|
+
</div>
|
|
161
260
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
261
|
+
<nav id="tabs">
|
|
262
|
+
<div class="tab active" data-i18n="tabOverview" onclick="showTab('overview')"></div>
|
|
263
|
+
<div class="tab" data-i18n="tabProviders" onclick="showTab('providers')"></div>
|
|
264
|
+
<div class="tab" data-i18n="tabModels" onclick="showTab('models')"></div>
|
|
265
|
+
<div class="tab" data-i18n="tabContext" onclick="showTab('context')"></div>
|
|
266
|
+
<div class="tab" data-i18n="tabSetup" onclick="showTab('setup')"></div>
|
|
267
|
+
</nav>
|
|
268
|
+
|
|
269
|
+
<div class="side-foot">
|
|
270
|
+
<div class="side-user">
|
|
271
|
+
<div class="side-avatar">F</div>
|
|
272
|
+
<div class="info">
|
|
273
|
+
<div class="email">Freegate</div>
|
|
274
|
+
<div class="role">Admin</div>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
</aside>
|
|
169
279
|
|
|
170
|
-
|
|
280
|
+
<div id="main">
|
|
281
|
+
<header id="topbar">
|
|
282
|
+
<div class="brand"><span id="statusDot" class="dot"></span>Панель управления</div>
|
|
283
|
+
<div class="theme-switch" title="">
|
|
284
|
+
<button class="theme-btn" id="themeBtn" onclick="cycleTheme()"></button>
|
|
285
|
+
</div>
|
|
286
|
+
<div class="lang-switch">
|
|
287
|
+
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">RU</button>
|
|
288
|
+
<button class="lang-btn" data-lang="en" onclick="setLang('en')">EN</button>
|
|
289
|
+
</div>
|
|
290
|
+
</header>
|
|
291
|
+
|
|
292
|
+
<div id="page-head">
|
|
293
|
+
<div class="crumb" data-i18n="crumb"></div>
|
|
294
|
+
<h1 data-i18n="pageTitle"></h1>
|
|
295
|
+
<div class="sub" data-i18n="pageSub"></div>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<div class="kpi" id="kpiBar"></div>
|
|
299
|
+
|
|
300
|
+
<main>
|
|
171
301
|
<!-- ═══ ОБЗОР ═══ -->
|
|
172
302
|
<section id="panel-overview" class="panel active">
|
|
173
303
|
<div id="alerts"></div>
|
|
@@ -235,9 +365,11 @@ const HTML = `<!DOCTYPE html>
|
|
|
235
365
|
</div>
|
|
236
366
|
<div id="setup-grid" class="setup-grid" data-i18n="loading"></div>
|
|
237
367
|
</section>
|
|
238
|
-
|
|
368
|
+
</main>
|
|
239
369
|
|
|
240
|
-
|
|
370
|
+
<div class="refresh" style="padding: 0 28px;" data-i18n="autoRefresh"></div>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
241
373
|
|
|
242
374
|
<script>
|
|
243
375
|
// Carry the ?key= param from the dashboard URL to API calls
|
|
@@ -251,8 +383,9 @@ const HTML = `<!DOCTYPE html>
|
|
|
251
383
|
ru: {
|
|
252
384
|
tabOverview: 'Обзор', tabProviders: 'Провайдеры', tabModels: 'Модели',
|
|
253
385
|
tabContext: 'Контекст', tabSetup: 'Настройки (ключи)',
|
|
386
|
+
crumb: 'Админ', pageTitle: 'Панель управления', pageSub: 'Мониторинг и управление Freegate',
|
|
254
387
|
kpiProviders: 'Провайдеры', kpiRequests: 'Запросы', kpiRpm: 'RPM', kpiCache: 'Кэш',
|
|
255
|
-
kpiLimit: 'Лимит', kpiModels: 'Модели', kpiUptime: 'Аптайм',
|
|
388
|
+
kpiToday: 'Успех сегодня', kpiLimit: 'Лимит', kpiModels: 'Модели', kpiUptime: 'Аптайм',
|
|
256
389
|
cardSavings: 'Экономия (против платных API)', savings: 'Сэкономлено', tokens: 'токенов',
|
|
257
390
|
savingsBasis: 'расчёт: input $3/M, output $15/M (репрезентативно)',
|
|
258
391
|
cardCache: 'Кэш', cardTokens: 'Токены', cardPool: 'Очередь',
|
|
@@ -287,8 +420,9 @@ const HTML = `<!DOCTYPE html>
|
|
|
287
420
|
en: {
|
|
288
421
|
tabOverview: 'Overview', tabProviders: 'Providers', tabModels: 'Models',
|
|
289
422
|
tabContext: 'Context', tabSetup: 'Settings (keys)',
|
|
423
|
+
crumb: 'Admin', pageTitle: 'Control Panel', pageSub: 'Freegate monitoring & management',
|
|
290
424
|
kpiProviders: 'Providers', kpiRequests: 'Requests', kpiRpm: 'RPM', kpiCache: 'Cache',
|
|
291
|
-
kpiLimit: 'Limit', kpiModels: 'Models', kpiUptime: 'Uptime',
|
|
425
|
+
kpiToday: 'Success today', kpiLimit: 'Limit', kpiModels: 'Models', kpiUptime: 'Uptime',
|
|
292
426
|
cardSavings: 'Savings (vs paid APIs)', savings: 'Saved', tokens: 'tokens',
|
|
293
427
|
savingsBasis: 'estimate: input $3/M, output $15/M (representative)',
|
|
294
428
|
cardCache: 'Cache', cardTokens: 'Tokens', cardPool: 'Queue',
|
|
@@ -331,6 +465,28 @@ const HTML = `<!DOCTYPE html>
|
|
|
331
465
|
btns.forEach(function (b) { b.classList.toggle('active', b.getAttribute('data-lang') === _lang); });
|
|
332
466
|
}
|
|
333
467
|
|
|
468
|
+
// ── темы: data-theme на body + переключатель ──
|
|
469
|
+
var THEME_LABELS = { poly: 'PolyCopy', warm: 'Тёплый', cosmic: 'Космос', paper: 'Бумага' };
|
|
470
|
+
var THEME_ORDER = ['poly', 'warm', 'cosmic', 'paper'];
|
|
471
|
+
var _theme = 'poly';
|
|
472
|
+
function applyTheme() {
|
|
473
|
+
document.body.setAttribute('data-theme', _theme);
|
|
474
|
+
var btn = document.getElementById('themeBtn');
|
|
475
|
+
if (btn) btn.textContent = (_lang === 'en' ? 'Theme' : 'Тема') + ': ' + (THEME_LABELS[_theme] || _theme);
|
|
476
|
+
}
|
|
477
|
+
function cycleTheme() {
|
|
478
|
+
var i = THEME_ORDER.indexOf(_theme);
|
|
479
|
+
_theme = THEME_ORDER[(i + 1) % THEME_ORDER.length];
|
|
480
|
+
localStorage.setItem('fg_theme', _theme);
|
|
481
|
+
applyTheme();
|
|
482
|
+
}
|
|
483
|
+
function setTheme(name) {
|
|
484
|
+
if (THEME_ORDER.indexOf(name) < 0) name = 'poly';
|
|
485
|
+
_theme = name; localStorage.setItem('fg_theme', name); applyTheme();
|
|
486
|
+
}
|
|
487
|
+
_theme = (new URLSearchParams(location.search).get('theme')) || localStorage.getItem('fg_theme') || 'poly';
|
|
488
|
+
if (THEME_ORDER.indexOf(_theme) < 0) _theme = 'poly';
|
|
489
|
+
|
|
334
490
|
// ── состояние клиента ──
|
|
335
491
|
var _health = {}, _limits = {}, _cache = null, _pool = {}, _statsData = null, _ctxSummary = null;
|
|
336
492
|
var _rpmData = [], _recent = [];
|
|
@@ -349,6 +505,8 @@ const HTML = `<!DOCTYPE html>
|
|
|
349
505
|
});
|
|
350
506
|
document.querySelectorAll('.panel').forEach(function (p) { p.classList.remove('active'); });
|
|
351
507
|
document.getElementById('panel-' + tab).classList.add('active');
|
|
508
|
+
// Шаринг вкладки по хэшу (#models и т.д.)
|
|
509
|
+
if (location.hash !== '#' + tab) { try { history.replaceState(null, '', '#' + tab); } catch (e) {} }
|
|
352
510
|
if (tab === 'setup' && !_setupData) loadSetup();
|
|
353
511
|
if (tab === 'models') fetchModels();
|
|
354
512
|
}
|
|
@@ -410,16 +568,22 @@ const HTML = `<!DOCTYPE html>
|
|
|
410
568
|
if (p > riskPct) { riskPct = p; riskKey = lk; }
|
|
411
569
|
}
|
|
412
570
|
var html = '';
|
|
413
|
-
html += '<div class="kpi-chip">' + t('kpiProviders') + '
|
|
414
|
-
html += '<div class="kpi-chip">' + t('kpiRequests') + '
|
|
415
|
-
html += '<div class="kpi-chip">' + t('kpiRpm') + '
|
|
416
|
-
html += '<div class="kpi-chip">' + t('kpiCache') + '
|
|
571
|
+
html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiProviders') + '</span><b>' + up + '/' + total + '</b></div>';
|
|
572
|
+
html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiRequests') + '</span><b>' + (_statsData ? _statsData.total_requests : 0) + '</b></div>';
|
|
573
|
+
html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiRpm') + '</span><b>' + rpm + '</b></div>';
|
|
574
|
+
html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiCache') + '</span><b>' + cacheRate + '%</b></div>';
|
|
575
|
+
// Успех сегодня — главная метрика ценности. Зелёная при высоком, жёлтая/красная при деградации.
|
|
576
|
+
if (_statsData && _statsData.today) {
|
|
577
|
+
var td = _statsData.today;
|
|
578
|
+
var tbCls = td.successRate >= 80 ? '' : td.successRate >= 50 ? ' warn' : ' err';
|
|
579
|
+
html += '<div class="kpi-chip' + tbCls + '"><span class="kpi-label">' + t('kpiToday') + '</span><b>' + td.successRate + '%</b></div>';
|
|
580
|
+
}
|
|
417
581
|
if (riskKey) {
|
|
418
582
|
var cls = riskPct > 90 ? ' err' : riskPct > 60 ? ' warn' : '';
|
|
419
|
-
html += '<div class="kpi-chip' + cls + '">' + t('kpiLimit') + ': ' + esc(riskKey) + '
|
|
583
|
+
html += '<div class="kpi-chip' + cls + '"><span class="kpi-label">' + t('kpiLimit') + ': ' + esc(riskKey) + '</span><b>' + riskPct + '%</b></div>';
|
|
420
584
|
}
|
|
421
|
-
if (_modelStats) html += '<div class="kpi-chip">' + t('kpiModels') + '
|
|
422
|
-
html += '<div class="kpi-chip">' + t('kpiUptime') + '
|
|
585
|
+
if (_modelStats) html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiModels') + '</span><b>' + (_modelStats.byStatus.active || 0) + '/' + _modelStats.total + '</b></div>';
|
|
586
|
+
html += '<div class="kpi-chip"><span class="kpi-label">' + t('kpiUptime') + '</span><b>' + fmtUptime(_statsData ? _statsData.uptime_seconds : 0) + '</b></div>';
|
|
423
587
|
document.getElementById('kpiBar').innerHTML = html;
|
|
424
588
|
var dot = document.getElementById('statusDot');
|
|
425
589
|
dot.className = 'dot ' + (up > 0 ? 'ok' : 'bad');
|
|
@@ -470,8 +634,11 @@ const HTML = `<!DOCTYPE html>
|
|
|
470
634
|
var sv = document.getElementById('savings');
|
|
471
635
|
if (sv) {
|
|
472
636
|
if (_savings && _savings.totalTokens > 0) {
|
|
637
|
+
var td = _statsData && _statsData.today;
|
|
638
|
+
var todayHtml = td ? '<div class="stat"><span class="stat-label">' + t('kpiToday') + '</span><span class="stat-value">' + td.successRate + '% (' + td.requests + ')</span></div>' : '';
|
|
473
639
|
sv.innerHTML =
|
|
474
640
|
'<div class="stat savings"><span class="stat-label">' + t('savings') + '</span><span class="stat-value savings-usd">$' + _savings.savedUsd.toFixed(2) + '</span></div>' +
|
|
641
|
+
todayHtml +
|
|
475
642
|
'<div class="stat"><span class="stat-label">' + t('tokens') + '</span><span class="stat-value">' + fmtTokens(_savings.totalTokens) + '</span></div>' +
|
|
476
643
|
'<div class="stat"><span class="stat-label">in / out</span><span class="stat-value" style="font-size:11px">' + fmtTokens(_savings.promptTokens) + ' / ' + fmtTokens(_savings.completionTokens) + '</span></div>' +
|
|
477
644
|
'<div style="margin-top:6px;font-size:10px;color:#888;">' + t('savingsBasis') + '</div>';
|
|
@@ -490,7 +657,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
490
657
|
var w = canvas.width, h = canvas.height, padL = 34, padB = 16, padT = 8;
|
|
491
658
|
ctx.clearRect(0, 0, w, h);
|
|
492
659
|
ctx.font = '10px monospace';
|
|
493
|
-
ctx.fillStyle = '#
|
|
660
|
+
ctx.fillStyle = '#5d6d7d';
|
|
494
661
|
var max = 1;
|
|
495
662
|
for (var i = 0; i < rpmData.length; i++) if (rpmData[i].count > max) max = rpmData[i].count;
|
|
496
663
|
// сетка: 4 линии
|
|
@@ -501,8 +668,9 @@ const HTML = `<!DOCTYPE html>
|
|
|
501
668
|
ctx.fillText(String(Math.round(max * g / 4)), 4, gy + 3);
|
|
502
669
|
}
|
|
503
670
|
if (!rpmData || rpmData.length === 0) { ctx.fillText('нет данных', padL + 10, h / 2); return; }
|
|
504
|
-
ctx.strokeStyle = '#
|
|
671
|
+
ctx.strokeStyle = '#e8b553';
|
|
505
672
|
ctx.lineWidth = 2;
|
|
673
|
+
ctx.shadowColor = '#e8b553'; ctx.shadowBlur = 4;
|
|
506
674
|
ctx.beginPath();
|
|
507
675
|
var step = (w - padL - 4) / Math.max(rpmData.length - 1, 1);
|
|
508
676
|
rpmData.forEach(function (r, i) {
|
|
@@ -511,6 +679,15 @@ const HTML = `<!DOCTYPE html>
|
|
|
511
679
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
512
680
|
});
|
|
513
681
|
ctx.stroke();
|
|
682
|
+
ctx.shadowBlur = 0;
|
|
683
|
+
// неоновая заливка под линией
|
|
684
|
+
ctx.lineTo(w - 4, h - padB);
|
|
685
|
+
ctx.lineTo(padL, h - padB);
|
|
686
|
+
ctx.closePath();
|
|
687
|
+
var grad = ctx.createLinearGradient(0, padT, 0, h - padB);
|
|
688
|
+
grad.addColorStop(0, 'rgba(232,181,83,.20)'); grad.addColorStop(1, 'rgba(232,181,83,0)');
|
|
689
|
+
ctx.fillStyle = grad;
|
|
690
|
+
ctx.fill();
|
|
514
691
|
ctx.fillText('-60м', padL, h - 3);
|
|
515
692
|
ctx.fillText('сейчас', w - 44, h - 3);
|
|
516
693
|
}
|
|
@@ -726,7 +903,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
726
903
|
// категории задач — прогресс-бары
|
|
727
904
|
var bars = '';
|
|
728
905
|
if (sum.tasks && sum.taskTotal > 0) {
|
|
729
|
-
var colors = { coding: '#
|
|
906
|
+
var colors = { coding: '#e8b553', reasoning: '#c9a0f0', search: '#7cc4e8', chat: '#e0a449', design: '#ec8fae' };
|
|
730
907
|
for (var tk in sum.tasks) {
|
|
731
908
|
var n = sum.tasks[tk];
|
|
732
909
|
if (n <= 0) continue;
|
|
@@ -816,9 +993,13 @@ const HTML = `<!DOCTYPE html>
|
|
|
816
993
|
}
|
|
817
994
|
|
|
818
995
|
applyLang();
|
|
996
|
+
applyTheme();
|
|
819
997
|
refresh();
|
|
820
998
|
setInterval(refresh, 5000);
|
|
821
999
|
setInterval(function () { _modelsFetchedAt = 0; fetchModels(); }, 30000);
|
|
1000
|
+
// Открыть запрошенную вкладку из хэша (#models, #providers, ...)
|
|
1001
|
+
var initial = (location.hash || '').replace('#', '');
|
|
1002
|
+
if (initial && _tabByKey[initial]) showTab(initial);
|
|
822
1003
|
// модели: подгружаем рекомендации один раз после первой загрузки
|
|
823
1004
|
var _isEn = _lang === 'en';
|
|
824
1005
|
</script>
|
package/lib/methodology.js
CHANGED
|
@@ -26,8 +26,28 @@ const METHODOLOGIES = {
|
|
|
26
26
|
'Методолог (chat): отвечай по существу и кратко, по-русски.',
|
|
27
27
|
'Если просят код или план — переключись на соответствующий стиль.',
|
|
28
28
|
].join('\n'),
|
|
29
|
+
design: [
|
|
30
|
+
'Методолог (design): перед кодом — определи смелую эстетическую идею. Не делай «generic AI-шаблон».',
|
|
31
|
+
'Выбери тон: минимализм / максимализм / ретро-футуризм / лакшери / брутализм / мягкий пастельный и т.д. — и держись его.',
|
|
32
|
+
'Типографика: подбери характерные шрифты (не Arial/Inter/Roboto), связка display + body.',
|
|
33
|
+
'Цвет: единая палитра через CSS-переменные, доминанта + резкий акцент.',
|
|
34
|
+
'Движение: одна хорошо срежиссированная загрузка (staggered reveals) и микро-взаимодействия; приоритет CSS-only.',
|
|
35
|
+
'Композиция и фон: асимметрия, наложение, негативное пространство, текстуры/градиенты/шум — вместо плоских заливок.',
|
|
36
|
+
'Реализуй рабочий код (HTML/CSS/JS, React, Vue). Адаптив mobile-first, доступность (контраст, aria).',
|
|
37
|
+
'Каждый дизайн — разный. Не повторяйся между проектами и не ходи в Space Grotesk по умолчанию.',
|
|
38
|
+
].join('\n'),
|
|
29
39
|
};
|
|
30
40
|
|
|
41
|
+
// Общий блок «поведение»: прививает модели сверхспособности — не сдаваться, а
|
|
42
|
+
// искать обходной путь, как DeepSeek. Применяется ко всем категориям независимо
|
|
43
|
+
// от переопределения промптов (config.methodology.prompts).
|
|
44
|
+
const BEHAVIOR = [
|
|
45
|
+
'Поведение (не сдавайся): если не находишь нужный инструмент/файл/скилл там, где ожидал —',
|
|
46
|
+
'не говори «не могу». Ищи обходной путь: читай файлы с диска напрямую (cat), проверь node_modules,',
|
|
47
|
+
'посмотри доступные инструменты, попробуй альтернативу. Сначала попробуй — потом решай.',
|
|
48
|
+
'Если всё же не выходит — одной строкой: что искал, где, и что предлагаешь сделать.',
|
|
49
|
+
].join('\n');
|
|
50
|
+
|
|
31
51
|
function getMethodology(category, options) {
|
|
32
52
|
const opts = options || {};
|
|
33
53
|
const prompts = (opts.prompts && typeof opts.prompts === 'object') ? opts.prompts : {};
|
|
@@ -63,8 +83,9 @@ function injectMethodology(messages, category, options) {
|
|
|
63
83
|
}
|
|
64
84
|
|
|
65
85
|
const copy = messages.slice();
|
|
66
|
-
|
|
86
|
+
const full = text + '\n\n' + BEHAVIOR;
|
|
87
|
+
copy.splice(insertAt, 0, { role: 'system', content: full });
|
|
67
88
|
return copy;
|
|
68
89
|
}
|
|
69
90
|
|
|
70
|
-
module.exports = { getMethodology, injectMethodology, enabledByDefault, METHODOLOGIES };
|
|
91
|
+
module.exports = { getMethodology, injectMethodology, enabledByDefault, METHODOLOGIES, BEHAVIOR };
|
package/lib/taskclassify.js
CHANGED
|
@@ -21,7 +21,33 @@ const REASONING_HINTS = [
|
|
|
21
21
|
const SEARCH_HINTS = [
|
|
22
22
|
'что такое', 'найди', 'где находится', 'как называется', 'что означает',
|
|
23
23
|
'объясни термин', 'find', 'search', 'lookup', 'что за', 'кто такой',
|
|
24
|
+
'как работает', 'как настроить', 'как установить', 'как использовать',
|
|
25
|
+
'расскажи про', 'расскажи о', 'про что', 'где скачать', 'где найти',
|
|
26
|
+
'найди информацию', 'поиск', 'что значит', 'в чем разница', 'как сделать',
|
|
24
27
|
];
|
|
28
|
+
// Слова, начинающие СОБСТВЕННО поиск/объяснение термина — перехватывают раньше
|
|
29
|
+
// reasoning, чтобы «что означает X» не уходило в «рассуждай пошагово».
|
|
30
|
+
const SEARCH_PREFIX_RE = /^(что (такое|значит|означает)|расскажи|объясни|как|где|кто|найди)\b/i;
|
|
31
|
+
|
|
32
|
+
// Frontend-дизайн: интерфейсы, вёрстка, UI/UX, макеты, лендинги, компоненты.
|
|
33
|
+
// Ловим ДО coding, т.к. «сверстай компонент» — это дизайн-задача, а не просто код.
|
|
34
|
+
const DESIGN_HINTS = [
|
|
35
|
+
'лендинг', 'landing', 'интерфейс', 'ui', 'ux', 'верстк', 'вёрстк', 'макет',
|
|
36
|
+
'фронтенд', 'frontend', 'front-end', 'hero', 'кнопку', 'кнопки', 'форма',
|
|
37
|
+
'форму', 'навбар', 'sidebar', 'карточк', 'галере', 'модал', 'адаптив',
|
|
38
|
+
'дизайн', 'шрифт', 'типограф', 'анимац', 'палитр', 'тёмн тем', 'светл тем',
|
|
39
|
+
'стили', 'tailwind', 'css', 'scss', 'jsx', 'компонент', 'страниц', 'элемент',
|
|
40
|
+
'секци', 'блок', 'визуал', 'красив', 'глянц', 'presentation', 'постер',
|
|
41
|
+
];
|
|
42
|
+
// Явный фронтенд-сигнал: просят UI-штуку, но это не про логику/бэкенд.
|
|
43
|
+
const DESIGN_UI_RE = /(сверста|нарису|создай|сделай|оформи|дизайн)\b.*(интерфейс|лендинг|верстк|вёрстк|страниц|компонент|ui|ux|form|макет|сайт|иконк|кнопк)/i;
|
|
44
|
+
const DESIGN_TECH_RE = /\b(react|vue|svelte|tailwind|css|scss|jsx|html|figma)\b/i;
|
|
45
|
+
// Вопрос/запрос о дизайне (не задача-создание): «что ты знаешь про дизайн»,
|
|
46
|
+
// «расскажи про минимакс дизайн» — это поиск фактов, а не создание UI.
|
|
47
|
+
// Иначе слово «дизайн» уводит запрос в методолог-дизайнер, и модель отвечает
|
|
48
|
+
// про стиль/минимализм вместо запрошенного предмета (MiniMax-нейросеть и т.п.).
|
|
49
|
+
// ВАЖНО: без \b после кириллицы — \b в JS не видит кириллические слова.
|
|
50
|
+
const DESIGN_QUERY_RE = /^(что (ты )?(знаешь|такое|такая|такой)|расскажи|опиши|найди инфу|что значит|как называется|кто такой).*(дизайн|ui|ux|верстк|вёрстк|лендинг)/i;
|
|
25
51
|
|
|
26
52
|
function lastUserText(messages) {
|
|
27
53
|
if (!Array.isArray(messages)) return '';
|
|
@@ -39,6 +65,19 @@ function classify(messages) {
|
|
|
39
65
|
const text = lastUserText(messages).trim();
|
|
40
66
|
if (!text) return 'chat';
|
|
41
67
|
const lower = text.toLowerCase();
|
|
68
|
+
// Вопрос о дизайне («что ты знаешь про дизайн») — это ПОИСК, а не задача
|
|
69
|
+
// создания UI. Ловим раньше design, иначе слово «дизайн» уводит модель в
|
|
70
|
+
// методолог-дизайнер и она отвечает про стиль вместо запрошенного предмета.
|
|
71
|
+
if (DESIGN_QUERY_RE.test(text)) return 'search';
|
|
72
|
+
// Frontend-дизайн: показываем UI-запросы как design, а не coding — агент
|
|
73
|
+
// отвечает как дизайнер (продуманный UI/UX), а не «просто код».
|
|
74
|
+
if (DESIGN_UI_RE.test(text)) return 'design';
|
|
75
|
+
if (DESIGN_TECH_RE.test(text) && /(свёрст|верстк|вёрстк|интерфейс|страниц|компонент|лендинг|ui|ux|макет)/i.test(text)) return 'design';
|
|
76
|
+
// Явные UI-сущности без глагола создания — тоже дизайн-работа («карточка
|
|
77
|
+
// товара», «навбар», «модалка»).
|
|
78
|
+
if (/(сверста|нарису|сделай|создай|оформи|добавь|допили|поправь)\b/i.test(text) &&
|
|
79
|
+
/(верстк|вёрстк|интерфейс|лендинг|карточк|кнопк|форму|навбар|модал|адаптив|компонент|блок|секци|страниц|бокс|панел)/i.test(text)) return 'design';
|
|
80
|
+
if (DESIGN_HINTS.some(h => h !== 'дизайн' && lower.includes(h))) return 'design';
|
|
42
81
|
|
|
43
82
|
// Код — сильный сигнал: читаем/пишем код, чиним, рефакторим.
|
|
44
83
|
if (looksLikeCode(messages)) return 'coding';
|
|
@@ -46,13 +85,15 @@ function classify(messages) {
|
|
|
46
85
|
if (/[a-zA-Z_$][\w$]*\s*\(/.test(text) && /=>|\{|;|return|\.\w+\(/.test(text)) return 'coding';
|
|
47
86
|
if (CODING_HINTS.some(h => lower.includes(h))) return 'coding';
|
|
48
87
|
|
|
88
|
+
// Поиск/объяснение термина — сильный сигнал, ловим ДО reasoning (иначе
|
|
89
|
+
// «что означает аннотация» уходит в «рассуждай пошагово», а это краткий факт).
|
|
90
|
+
if (SEARCH_PREFIX_RE.test(text)) return 'search';
|
|
91
|
+
if (SEARCH_HINTS.some(h => lower.includes(h))) return 'search';
|
|
92
|
+
|
|
49
93
|
// Рассуждения — числа, подсчёт, объяснение причин.
|
|
50
94
|
if (/\d/.test(text) && /[+\-*/%]|сколько|посчитай|рассчитай/.test(lower)) return 'reasoning';
|
|
51
95
|
if (REASONING_HINTS.some(h => lower.includes(h))) return 'reasoning';
|
|
52
96
|
|
|
53
|
-
// Поиск — спрашивают, что это такое, где найти.
|
|
54
|
-
if (SEARCH_HINTS.some(h => lower.includes(h))) return 'search';
|
|
55
|
-
|
|
56
97
|
return 'chat';
|
|
57
98
|
}
|
|
58
99
|
|
package/lib/websearch.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// lib/websearch.js — веб-поиск для search-задач (бесплатно, без ключа).
|
|
2
|
+
// Используется, чтобы Freegate находил факты вместо галлюцинаций («что такое
|
|
3
|
+
// минимакс дизайн» → реальные данные о MiniMax, а не «минимализм»).
|
|
4
|
+
// Источник: DuckDuckGo HTML (без API-ключа). Вся сеть через injectable fetchImpl.
|
|
5
|
+
// parseDdg() — парсер HTML; search() — обёртка с таймаутом и защитой от сбоев.
|
|
6
|
+
|
|
7
|
+
const DDG_URL = 'https://html.duckduckgo.com/html/';
|
|
8
|
+
|
|
9
|
+
const isObject = (v) => v && typeof v === 'object';
|
|
10
|
+
|
|
11
|
+
// Декодирует URL, обфусцированный DDG (//duckduckgo.com/l/?uddg=<enc>&rut=..).
|
|
12
|
+
function decodeUrl(raw) {
|
|
13
|
+
if (!raw) return '';
|
|
14
|
+
if (!raw.includes('uddg=')) return raw;
|
|
15
|
+
try {
|
|
16
|
+
const m = raw.match(/[?&]uddg=([^&]+)/);
|
|
17
|
+
if (!m) return raw;
|
|
18
|
+
return decodeURIComponent(m[1]);
|
|
19
|
+
} catch {
|
|
20
|
+
return raw;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Извлекает блоки результатов: title, url, snippet.
|
|
25
|
+
function parseDdg(html) {
|
|
26
|
+
const out = [];
|
|
27
|
+
if (!html || typeof html !== 'string') return out;
|
|
28
|
+
// Каждый результат — блок с result__a (заголовок+ссылка) и опц. result__snippet.
|
|
29
|
+
const blocks = html.split('class="result results_links').slice(1);
|
|
30
|
+
for (const b of blocks) {
|
|
31
|
+
const titleM = b.match(/class="result__a"[^>]*>([^<]+)</);
|
|
32
|
+
const hrefM = b.match(/class="result__a"[^>]*href="([^"]+)"/);
|
|
33
|
+
const snipM = b.match(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/);
|
|
34
|
+
if (!titleM) continue;
|
|
35
|
+
const title = titleM[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim();
|
|
36
|
+
const url = decodeUrl(hrefM ? hrefM[1] : '');
|
|
37
|
+
const snippet = snipM
|
|
38
|
+
? snipM[1].replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/\.\.\./g, '').trim()
|
|
39
|
+
: '';
|
|
40
|
+
if (title && url) out.push({ title, url, snippet: snippet.slice(0, 300) });
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Поиск. Возвращает [{title,url,snippet}]; при любой ошибке — [] (не бросает).
|
|
46
|
+
async function search(query, { fetchImpl, limit = 5, timeout = 8000 } = {}) {
|
|
47
|
+
if (!fetchImpl || !query) return [];
|
|
48
|
+
// DDG предпочитает '+' для пробелов (encodeURIComponent даёт %20 → может вернуть
|
|
49
|
+
// anti-bot 202 без результатов). Кодируем, затем пробелы → '+'.
|
|
50
|
+
const p = encodeURIComponent(query).replace(/%20/g, '+');
|
|
51
|
+
const url = DDG_URL + '?q=' + p;
|
|
52
|
+
try {
|
|
53
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
54
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeout) : null;
|
|
55
|
+
const res = await fetchImpl(url, {
|
|
56
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36' },
|
|
57
|
+
signal: controller ? controller.signal : undefined,
|
|
58
|
+
});
|
|
59
|
+
if (timer) clearTimeout(timer);
|
|
60
|
+
if (!res || !res.ok) return [];
|
|
61
|
+
const html = await res.text();
|
|
62
|
+
return parseDdg(html).slice(0, limit);
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Собирает контекст для промпта: заголовки + ссылки + сниппеты.
|
|
69
|
+
function toContext(results, query) {
|
|
70
|
+
if (!Array.isArray(results) || results.length === 0) return '';
|
|
71
|
+
const lines = results.map((r, i) => `${i + 1}. ${r.title} — ${r.url}${r.snippet ? ' | ' + r.snippet : ''}`);
|
|
72
|
+
return 'Актуальные данные из веб-поиска («' + query + '»):\n' + lines.join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { DDG_URL, decodeUrl, parseDdg, search, toContext, isObject };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freegate",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.18",
|
|
4
4
|
"description": "Free multi-provider LLM gateway with automatic failover. One OpenAI-compatible endpoint routes to 25 free models (Groq, Mistral, Gemini, NIM, OpenRouter, ZAI, Cerebras, DeepSeek). Never pay for LLMs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/providers.json
CHANGED
package/server.js
CHANGED
|
@@ -4,11 +4,12 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { LRUCache } = require('./lib/cache');
|
|
6
6
|
const { PROVIDERS, MODEL_MAP, callProvider, reloadProviders } = require('./lib/providers');
|
|
7
|
-
const { loadState, initHealth, isCircuitOpen, recordSuccess, recordFailure, recordRequest, recordTokens, getHealth, getStats, recordRecent, recordRpm, getRecent, getRpm, recordSelection, getLastSelection, getBandit, recordBandit, warmBanditPriors, getContextStats } = require('./lib/health');
|
|
7
|
+
const { loadState, initHealth, isCircuitOpen, recordSuccess, recordFailure, recordRequest, recordTokens, getHealth, getStats, getReliability, recordRecent, recordRpm, getRecent, getRpm, recordSelection, getLastSelection, getBandit, recordBandit, warmBanditPriors, getContextStats } = require('./lib/health');
|
|
8
8
|
const { checkRateLimit } = require('./lib/rateLimit');
|
|
9
9
|
const { handleDashboard } = require('./lib/dashboard');
|
|
10
10
|
const { acquire, stats: poolStats } = require('./lib/pool');
|
|
11
11
|
const { aggregateSavings } = require('./lib/economics');
|
|
12
|
+
const websearch = require('./lib/websearch');
|
|
12
13
|
const { stripThink, cleanDelta, cleanMessage, fixReasoningMessage, isTooShort, MIN_ANSWER_LEN } = require('./lib/clean');
|
|
13
14
|
const { classifyComplexity, maybeUpgradeTier, classifyVisionComplexity, needsWindowUpgrade } = require('./lib/routing');
|
|
14
15
|
const { bucket, pick: banditPick, isTransientLimit } = require('./lib/bandit');
|
|
@@ -42,7 +43,10 @@ const warmed = warmBanditPriors(
|
|
|
42
43
|
);
|
|
43
44
|
if (warmed > 0) logger.info('Warmed bandit priors', { warmed });
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
// Диск-кэш: повторы промптов не жгут free-лимиты. TTL 24ч (чаты/агенты часто
|
|
47
|
+
// переотправляют одинаковые запросы — ретраи, повторные вопросы; дневной кэш
|
|
48
|
+
// снимает лишнюю нагрузку). Семантический normalize ON (4-й арг).
|
|
49
|
+
const cache = new LRUCache(500, 24 * 3600 * 1000, false, true);
|
|
46
50
|
require('./lib/cache')._activeCache = cache;
|
|
47
51
|
|
|
48
52
|
// Load config (with fallback so a corrupt config never crashes the server)
|
|
@@ -95,6 +99,15 @@ const METHODOLOGY_CONFIG = Object.assign(
|
|
|
95
99
|
(config.methodology && typeof config.methodology === 'object') ? config.methodology : {}
|
|
96
100
|
);
|
|
97
101
|
|
|
102
|
+
// --- Веб-поиск для search-задач ---
|
|
103
|
+
// Бесплатный поиск фактов (DuckDuckGo, без ключа) для запросов-поиска, чтобы
|
|
104
|
+
// модель не галлюцинировала («что такое минимакс дизайн» → реальная инфа про
|
|
105
|
+
// MiniMax, а не «минимализм»). Только для search-задач. Сбои не ломают запрос.
|
|
106
|
+
const WEBSEARCH_CONFIG = Object.assign(
|
|
107
|
+
{ enabled: true, limit: 5, timeout: 8000, queryMinChars: 6 },
|
|
108
|
+
(config.websearch && typeof config.websearch === 'object') ? config.websearch : {}
|
|
109
|
+
);
|
|
110
|
+
|
|
98
111
|
// --- Самообновляющаяся база моделей (Model Discovery Engine) ---
|
|
99
112
|
// Планировщик живёт внутри сервера: работает «всегда» у всех пользователей
|
|
100
113
|
// пакета без cron/launchd. config.modelManager.enabled=false отключает.
|
|
@@ -456,6 +469,35 @@ async function handleChatCompletion(req, res, body) {
|
|
|
456
469
|
}
|
|
457
470
|
}
|
|
458
471
|
|
|
472
|
+
// --- Веб-поиск для search-задач ---
|
|
473
|
+
// Нашли факты из интернета (DuckDuckGo, без ключа) и подмешали как контекст,
|
|
474
|
+
// чтобы модель отвечала по существу, а не галлюцинировала. Только search.
|
|
475
|
+
// Любой сбой (нет сети/таймаут/пусто) = просто пропускаем, запрос идёт как есть.
|
|
476
|
+
let searchContext = '';
|
|
477
|
+
if (WEBSEARCH_CONFIG.enabled && (taskCategory === 'search' || taskCategory === 'chat')) {
|
|
478
|
+
try {
|
|
479
|
+
const userTexts = (body.messages || [])
|
|
480
|
+
.filter(m => m && m.role === 'user' && typeof m.content === 'string')
|
|
481
|
+
.map(m => m.content.trim());
|
|
482
|
+
const query = userTexts[userTexts.length - 1] || '';
|
|
483
|
+
if (query.length >= WEBSEARCH_CONFIG.queryMinChars) {
|
|
484
|
+
const results = await websearch.search(query, {
|
|
485
|
+
fetchImpl: (url, opts) => fetch(url, opts),
|
|
486
|
+
limit: WEBSEARCH_CONFIG.limit,
|
|
487
|
+
timeout: WEBSEARCH_CONFIG.timeout,
|
|
488
|
+
});
|
|
489
|
+
if (results.length > 0) {
|
|
490
|
+
searchContext = websearch.toContext(results, query);
|
|
491
|
+
measure.websearch = true;
|
|
492
|
+
body.messages.push({ role: 'system', content: searchContext });
|
|
493
|
+
logger.info('Websearch', { query: query.slice(0, 80), results: results.length });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
} catch (err) {
|
|
497
|
+
logger.error('Websearch error', { message: err.message });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
459
501
|
// Replays a previously cached completion (exact or semantic hit), preserving
|
|
460
502
|
// the stream/non-stream shape the client asked for.
|
|
461
503
|
function serveCached(res, cached, isStreaming) {
|
|
@@ -539,13 +581,22 @@ async function handleChatCompletion(req, res, body) {
|
|
|
539
581
|
|
|
540
582
|
// Prefer providers below 90% of their daily limit; only fall back to
|
|
541
583
|
// near-exhausted ones if that leaves nothing (avoids avoidable 429s).
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
584
|
+
// Крутящийся пул по дневным лимитам: провайдер, исчерпавший дневной лимит,
|
|
585
|
+
// исключается из выбора и не возвращается до сброса. Это лечит 429-спираль
|
|
586
|
+
// (модель с лимитом 50/день сгорает к обеду, дальше каждая попытка на ней —
|
|
587
|
+
// бесполезный 429). Остаёмся на живых; выгоревшие — только как крайний резерв.
|
|
588
|
+
const usedTodayFor = (key) => (getStats().dailyUsage?.[key]?.[today]) || 0;
|
|
589
|
+
const pool = (() => {
|
|
590
|
+
// Провайдеры, у которых дневной лимит ещё не исчерпан (strict < limit).
|
|
591
|
+
const exemptables = windowPool.filter(([_, p]) => {
|
|
592
|
+
const limit = p.dailyLimit || 0;
|
|
593
|
+
if (limit <= 0) return true; // нет лимита — считаем «бесконечным»
|
|
594
|
+
return usedTodayFor(p.key) < limit;
|
|
595
|
+
});
|
|
596
|
+
// Есть живые → только они. Все выгорели → вернуть весь пул (best-effort,
|
|
597
|
+
// bandit-штраф ниже сделает их маловероятными, но не невозможными).
|
|
598
|
+
return exemptables.length > 0 ? exemptables : windowPool;
|
|
599
|
+
})();
|
|
549
600
|
|
|
550
601
|
let selected = [];
|
|
551
602
|
if (pool.length > 0) {
|
|
@@ -569,11 +620,26 @@ async function handleChatCompletion(req, res, body) {
|
|
|
569
620
|
// не исключая fallback. coding→coding, reasoning→reasoning, chat/search→general.
|
|
570
621
|
const cat = provider.category || 'general';
|
|
571
622
|
if (taskCategory === 'coding' && cat === 'coding') weight *= 1.5;
|
|
623
|
+
else if (taskCategory === 'design' && cat === 'coding') weight *= 1.6;
|
|
572
624
|
else if (taskCategory === 'reasoning' && cat === 'reasoning') weight *= 1.5;
|
|
573
625
|
else if ((taskCategory === 'chat' || taskCategory === 'search') && cat === 'general') weight *= 1.2;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
626
|
+
// Дизайн — это UI-код: визуальные рекомендации идут от coding-моделей.
|
|
627
|
+
else if (taskCategory === 'design' && cat === 'reasoning') weight *= 0.4;
|
|
628
|
+
// Простой факт/поиск не должен «думать вслух» на reasoning-модели:
|
|
629
|
+
// это дорого по лимитам и даёт размышления вместо краткого ответа.
|
|
630
|
+
if ((taskCategory === 'chat' || taskCategory === 'search') && cat === 'reasoning') weight *= 0.15;
|
|
631
|
+
else if ((taskCategory === 'chat' || taskCategory === 'search') && cat === 'vision') weight *= 0.3;
|
|
632
|
+
// Поиск фактов — не кодинг-задача: codestral (coding) на вопросе
|
|
633
|
+
// «что такое минимакс дизайн» выдаёт рассуждение про «минимализм».
|
|
634
|
+
// Отдаём search на general-модели (minimax/small), а не на кодеров.
|
|
635
|
+
if (taskCategory === 'search' && cat === 'coding') weight *= 0.3;
|
|
636
|
+
const dailyLimit = provider.dailyLimit || 0;
|
|
637
|
+
// Провайдер, исчерпавший дневной лимит (попал в пул лишь как крайний
|
|
638
|
+
// резерв, когда живы все выгорели), — сильно штрафуем, чтобы выбрать
|
|
639
|
+
// его только в безвыходной ситуации, а не в первой же попытке.
|
|
640
|
+
const usedToday = usedTodayFor(key);
|
|
641
|
+
if (dailyLimit > 0 && usedToday >= dailyLimit) weight *= 0.03;
|
|
642
|
+
else if (dailyLimit > 0 && usedToday >= dailyLimit * 0.9) weight *= 0.4;
|
|
577
643
|
return { key, provider, weight };
|
|
578
644
|
});
|
|
579
645
|
|
|
@@ -617,13 +683,19 @@ async function handleChatCompletion(req, res, body) {
|
|
|
617
683
|
const tLimit = (getStats().dailyUsage?.[targetProviderKey]?.[today]) || 0;
|
|
618
684
|
const tCap = PROVIDERS[targetProviderKey].dailyLimit || 0;
|
|
619
685
|
const targetBurned = tHealth?.status === 'ratelimited' || (tCap > 0 && tLimit >= tCap * 0.9);
|
|
620
|
-
|
|
686
|
+
// Кодинг-target (codestral) не подходит для поиска фактов: на вопросе
|
|
687
|
+
// «что такое минимакс дизайн» он отвечает «минимализм», а не про нейросеть.
|
|
688
|
+
// Для search/chat-задач НЕ ставим кодинг-модель первой — пусть выберётся
|
|
689
|
+
// general-модель (minimax/small), которая отвечает по существу.
|
|
690
|
+
const targetIsCoding = (PROVIDERS[targetProviderKey].category || '') === 'coding';
|
|
691
|
+
const targetMismatch = (taskCategory === 'search' || taskCategory === 'chat') && targetIsCoding;
|
|
692
|
+
if (!targetBurned && !targetMismatch) {
|
|
621
693
|
enabledProviders = [
|
|
622
694
|
[targetProviderKey, PROVIDERS[targetProviderKey]],
|
|
623
695
|
...enabledProviders.filter(([k]) => k !== targetProviderKey),
|
|
624
696
|
];
|
|
625
697
|
} else {
|
|
626
|
-
logger.info('Target-first skip', { key: targetProviderKey, status: tHealth?.status, used: tLimit, cap: tCap });
|
|
698
|
+
logger.info('Target-first skip', { key: targetProviderKey, status: tHealth?.status, used: tLimit, cap: tCap, mismatch: targetMismatch });
|
|
627
699
|
}
|
|
628
700
|
}
|
|
629
701
|
|
|
@@ -1252,6 +1324,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1252
1324
|
res.end(JSON.stringify({
|
|
1253
1325
|
total_requests: s.totalRequests, successful_requests: s.successfulRequests, failed_requests: s.failedRequests,
|
|
1254
1326
|
provider_usage: s.providerUsage, token_usage: s.tokenUsage, errors: s.errors, uptime_seconds: Math.floor((Date.now() - s.startTime) / 1000),
|
|
1327
|
+
today: (() => {
|
|
1328
|
+
const r = getReliability();
|
|
1329
|
+
let success = 0, fail = 0;
|
|
1330
|
+
for (const [_, v] of Object.entries(r)) { if (v && v.day === today) { success += v.success || 0; fail += v.fail || 0; } }
|
|
1331
|
+
const total = success + fail;
|
|
1332
|
+
return { requests: total, success, failed: fail, successRate: total > 0 ? Math.round((success / total) * 100) : 0 };
|
|
1333
|
+
})(),
|
|
1255
1334
|
savings: (() => { try { return aggregateSavings(s.tokenUsage || {}); } catch { return null; } })(),
|
|
1256
1335
|
health: Object.fromEntries(Object.entries(getHealth()).map(([k, v]) => {
|
|
1257
1336
|
const limit = limits[k];
|