freegate 0.6.17 → 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 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
- ![Freegate Dashboard](assets/dashboard.png)
32
+ ![Freegate Dashboard](assets/dashboard.gif)
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
  ![Freegate Models base](assets/dashboard-models.png)
@@ -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
- ![Дашборд Freegate](assets/dashboard.png)
31
+ ![Дашборд Freegate](assets/dashboard.gif)
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
  ![База моделей Freegate](assets/dashboard-models.png)
@@ -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
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}`;
@@ -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",
@@ -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='%2300d4ff'/%3E%3C/svg%3E">
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
- <style>
13
+ <style>
14
+ /* ── Aesthetic: современный тёмный админ-дашборд (в стиле PolyCopy) ── */
14
15
  :root {
15
- --bg: #0d1117; --panel: #161b22; --panel2: #1c2330; --border: #232a3d;
16
- --accent: #00d4ff; --ok: #00ff88; --warn: #ffaa00; --err: #ff4444;
17
- --text: #e6e6e6; --muted: #8b949e;
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', monospace; background: var(--bg); color: var(--text); padding-bottom: 40px; }
21
-
22
- /* ── Sticky KPI-полоска ── */
23
- #topbar { position: sticky; top: 0; z-index: 10; background: rgba(13,17,23,.95); backdrop-filter: blur(6px);
24
- border-bottom: 1px solid var(--border); padding: 12px 20px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
25
- .brand { font-size: 17px; font-weight: bold; color: var(--accent); display: flex; align-items: center; gap: 8px; margin-right: 8px; }
26
- .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; background: var(--muted); }
27
- .dot.ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
28
- .dot.bad { background: var(--err); box-shadow: 0 0 6px var(--err); }
29
- .kpi { display: flex; flex-wrap: wrap; gap: 8px; }
30
- .kpi-chip { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; font-size: 12px; }
31
- .kpi-chip b { color: var(--accent); font-size: 13px; margin-left: 6px; }
32
- .kpi-chip.warn b { color: var(--warn); }
33
- .kpi-chip.err b { color: var(--err); }
34
-
35
- /* ── Табы ── */
36
- #tabs { display: flex; gap: 6px; padding: 12px 20px 0; flex-wrap: wrap; }
37
- .tab { padding: 9px 18px; border-radius: 8px 8px 0 0; cursor: pointer; background: var(--panel);
38
- color: var(--muted); border: 1px solid var(--border); border-bottom: none; font-size: 13px; user-select: none; }
39
- .tab.active { color: var(--accent); background: var(--panel2); border-color: var(--accent); }
40
- .tab:hover { color: var(--text); }
41
-
42
- main { padding: 0 20px; }
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(300px, 1fr)); gap: 16px; }
55
- .card { background: var(--panel); border-radius: 10px; padding: 16px 18px; border: 1px solid var(--border); }
56
- .card h2 { color: var(--accent); font-size: 12px; margin-bottom: 12px; text-transform: uppercase; letter-spacing: .5px; }
57
- .stat { display: flex; justify-content: space-between; margin-bottom: 7px; gap: 10px; }
58
- .stat-label { color: var(--muted); }
59
- .stat-value { color: #fff; font-weight: bold; text-align: right; }
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: 10px 14px; border-radius: 8px; font-size: 13px; border: 1px solid; }
64
- .alert.err { background: rgba(255,68,68,.08); border-color: var(--err); color: #ffb3b3; }
65
- .alert.warn { background: rgba(255,170,0,.07); border-color: var(--warn); color: #ffd999; }
66
- .alert.ok { background: rgba(0,255,136,.06); border-color: var(--ok); color: #b3ffd9; }
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: 200px; max-width: 340px; padding: 8px 12px; border-radius: 8px;
71
- border: 1px solid var(--border); background: var(--panel); color: #fff; font-size: 13px; outline: none; }
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: 8px 10px; border-radius: 8px; border: 1px solid var(--border);
74
- background: var(--panel); color: var(--text); font-size: 12px; outline: none; cursor: pointer; }
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: 6px 12px; border-radius: 999px; background: var(--panel); border: 1px solid var(--border);
77
- color: var(--muted); font-size: 12px; cursor: pointer; user-select: none; }
78
- .chip.active { color: var(--accent); border-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: 10px; border: 1px solid var(--border); }
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: 9px 12px; border-bottom: 1px solid var(--border); font-size: 12.5px; white-space: nowrap; }
85
- th { color: var(--accent); font-size: 11px; text-transform: uppercase; letter-spacing: .4px; cursor: pointer; user-select: none; }
86
- th:hover { color: #fff; }
87
- th .arr { font-size: 9px; margin-left: 3px; }
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:hover td { background: rgba(0,212,255,.03); }
90
- .badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: bold; display: inline-block; }
91
- .badge.up, .badge.active { background: rgba(0,255,136,.12); color: var(--ok); }
92
- .badge.ratelimited { background: rgba(255,170,0,.12); color: var(--warn); }
93
- .badge.error, .badge.dead { background: rgba(255,68,68,.12); color: var(--err); }
94
- .badge.unknown, .badge.untested { background: rgba(139,148,158,.15); color: var(--muted); }
95
- .badge.disabled, .badge.user-disabled { background: rgba(139,148,158,.1); color: var(--muted); }
96
- .limit-bar { width: 110px; height: 6px; background: var(--panel2); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-right: 7px; }
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: bold; color: var(--accent); }
99
- .mono { font-family: ui-monospace, Menlo, monospace; font-size: 11.5px; }
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: 4px 9px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel2);
104
- color: var(--text); font-size: 11px; cursor: pointer; }
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: 12px; margin-bottom: 7px; line-height: 1.5; }
110
- .rec-badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: bold; margin-right: 6px; }
111
- .rec-badge.ok { background: rgba(0,255,136,.12); color: var(--ok); }
112
- .rec-badge.warn { background: rgba(255,170,0,.12); color: var(--warn); }
113
- .rec-badge { background: rgba(139,148,158,.15); color: var(--muted); }
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: 24px; }
210
+ .savings .savings-usd { color: var(--ok); font-size: 28px; font-weight: 700; letter-spacing: -.5px; }
117
211
 
118
- canvas { width: 100%; height: 160px; background: var(--panel); border-radius: 10px; border: 1px solid var(--border); }
119
- .refresh { color: var(--muted); font-size: 11px; margin-top: 14px; }
120
- .task-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 9px; font-size: 12px; }
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%; background: var(--accent); border-radius: 5px; }
124
- .task-bar .num { width: 74px; text-align: right; }
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: 10px; padding: 16px; border: 1px solid var(--border); }
129
- .key-card h3 { color: var(--accent); font-size: 14px; margin-bottom: 8px; }
130
- .key-card .desc { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
131
- .key-card ol { color: #aaa; font-size: 11px; padding-left: 18px; margin-bottom: 10px; }
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); text-decoration: none; }
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: 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: #fff; font-size: 12px; font-family: monospace; }
136
- .btn { padding: 8px 14px; border-radius: 6px; border: none; cursor: pointer; font-size: 12px; font-weight: bold; }
137
- .btn-validate { background: var(--panel2); color: var(--accent); }
138
- .btn-save { background: var(--accent); color: #000; }
139
- .btn-test-all { background: var(--panel2); color: var(--ok); }
140
- .status-ok { color: var(--ok); font-size: 11px; }
141
- .status-err { color: var(--err); font-size: 11px; }
142
- .status-wait { color: var(--muted); font-size: 11px; }
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: 10px 12px; }
147
- main, #tabs { padding-left: 12px; padding-right: 12px; }
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
- <header id="topbar">
154
- <div class="brand"><span id="statusDot" class="dot"></span>Freegate</div>
155
- <div class="kpi" id="kpiBar"></div>
156
- <div class="lang-switch">
157
- <button class="lang-btn" data-lang="ru" onclick="setLang('ru')">RU</button>
158
- <button class="lang-btn" data-lang="en" onclick="setLang('en')">EN</button>
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>
260
+
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>
161
279
 
162
- <nav id="tabs">
163
- <div class="tab active" data-i18n="tabOverview" onclick="showTab('overview')"></div>
164
- <div class="tab" data-i18n="tabProviders" onclick="showTab('providers')"></div>
165
- <div class="tab" data-i18n="tabModels" onclick="showTab('models')"></div>
166
- <div class="tab" data-i18n="tabContext" onclick="showTab('context')"></div>
167
- <div class="tab" data-i18n="tabSetup" onclick="showTab('setup')"></div>
168
- </nav>
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>
169
299
 
170
- <main>
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
- </main>
368
+ </main>
239
369
 
240
- <div class="refresh" style="padding: 0 20px;" data-i18n="autoRefresh"></div>
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,6 +383,7 @@ 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
388
  kpiToday: 'Успех сегодня', kpiLimit: 'Лимит', kpiModels: 'Модели', kpiUptime: 'Аптайм',
256
389
  cardSavings: 'Экономия (против платных API)', savings: 'Сэкономлено', tokens: 'токенов',
@@ -287,6 +420,7 @@ 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
425
  kpiToday: 'Success today', kpiLimit: 'Limit', kpiModels: 'Models', kpiUptime: 'Uptime',
292
426
  cardSavings: 'Savings (vs paid APIs)', savings: 'Saved', tokens: 'tokens',
@@ -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,22 +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') + '<b>' + up + '/' + total + '</b></div>';
414
- html += '<div class="kpi-chip">' + t('kpiRequests') + '<b>' + (_statsData ? _statsData.total_requests : 0) + '</b></div>';
415
- html += '<div class="kpi-chip">' + t('kpiRpm') + '<b>' + rpm + '</b></div>';
416
- html += '<div class="kpi-chip">' + t('kpiCache') + '<b>' + cacheRate + '%</b></div>';
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>';
417
575
  // Успех сегодня — главная метрика ценности. Зелёная при высоком, жёлтая/красная при деградации.
418
576
  if (_statsData && _statsData.today) {
419
577
  var td = _statsData.today;
420
578
  var tbCls = td.successRate >= 80 ? '' : td.successRate >= 50 ? ' warn' : ' err';
421
- html += '<div class="kpi-chip' + tbCls + '">' + t('kpiToday') + '<b>' + td.successRate + '%</b></div>';
579
+ html += '<div class="kpi-chip' + tbCls + '"><span class="kpi-label">' + t('kpiToday') + '</span><b>' + td.successRate + '%</b></div>';
422
580
  }
423
581
  if (riskKey) {
424
582
  var cls = riskPct > 90 ? ' err' : riskPct > 60 ? ' warn' : '';
425
- html += '<div class="kpi-chip' + cls + '">' + t('kpiLimit') + ': ' + esc(riskKey) + '<b>' + riskPct + '%</b></div>';
583
+ html += '<div class="kpi-chip' + cls + '"><span class="kpi-label">' + t('kpiLimit') + ': ' + esc(riskKey) + '</span><b>' + riskPct + '%</b></div>';
426
584
  }
427
- if (_modelStats) html += '<div class="kpi-chip">' + t('kpiModels') + '<b>' + (_modelStats.byStatus.active || 0) + '/' + _modelStats.total + '</b></div>';
428
- html += '<div class="kpi-chip">' + t('kpiUptime') + '<b>' + fmtUptime(_statsData ? _statsData.uptime_seconds : 0) + '</b></div>';
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>';
429
587
  document.getElementById('kpiBar').innerHTML = html;
430
588
  var dot = document.getElementById('statusDot');
431
589
  dot.className = 'dot ' + (up > 0 ? 'ok' : 'bad');
@@ -499,7 +657,7 @@ const HTML = `<!DOCTYPE html>
499
657
  var w = canvas.width, h = canvas.height, padL = 34, padB = 16, padT = 8;
500
658
  ctx.clearRect(0, 0, w, h);
501
659
  ctx.font = '10px monospace';
502
- ctx.fillStyle = '#8b949e';
660
+ ctx.fillStyle = '#5d6d7d';
503
661
  var max = 1;
504
662
  for (var i = 0; i < rpmData.length; i++) if (rpmData[i].count > max) max = rpmData[i].count;
505
663
  // сетка: 4 линии
@@ -510,8 +668,9 @@ const HTML = `<!DOCTYPE html>
510
668
  ctx.fillText(String(Math.round(max * g / 4)), 4, gy + 3);
511
669
  }
512
670
  if (!rpmData || rpmData.length === 0) { ctx.fillText('нет данных', padL + 10, h / 2); return; }
513
- ctx.strokeStyle = '#00d4ff';
671
+ ctx.strokeStyle = '#e8b553';
514
672
  ctx.lineWidth = 2;
673
+ ctx.shadowColor = '#e8b553'; ctx.shadowBlur = 4;
515
674
  ctx.beginPath();
516
675
  var step = (w - padL - 4) / Math.max(rpmData.length - 1, 1);
517
676
  rpmData.forEach(function (r, i) {
@@ -520,6 +679,15 @@ const HTML = `<!DOCTYPE html>
520
679
  if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
521
680
  });
522
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();
523
691
  ctx.fillText('-60м', padL, h - 3);
524
692
  ctx.fillText('сейчас', w - 44, h - 3);
525
693
  }
@@ -735,7 +903,7 @@ const HTML = `<!DOCTYPE html>
735
903
  // категории задач — прогресс-бары
736
904
  var bars = '';
737
905
  if (sum.tasks && sum.taskTotal > 0) {
738
- var colors = { coding: '#00d4ff', reasoning: '#b388ff', search: '#00ff88', chat: '#ffaa00' };
906
+ var colors = { coding: '#e8b553', reasoning: '#c9a0f0', search: '#7cc4e8', chat: '#e0a449', design: '#ec8fae' };
739
907
  for (var tk in sum.tasks) {
740
908
  var n = sum.tasks[tk];
741
909
  if (n <= 0) continue;
@@ -825,9 +993,13 @@ const HTML = `<!DOCTYPE html>
825
993
  }
826
994
 
827
995
  applyLang();
996
+ applyTheme();
828
997
  refresh();
829
998
  setInterval(refresh, 5000);
830
999
  setInterval(function () { _modelsFetchedAt = 0; fetchModels(); }, 30000);
1000
+ // Открыть запрошенную вкладку из хэша (#models, #providers, ...)
1001
+ var initial = (location.hash || '').replace('#', '');
1002
+ if (initial && _tabByKey[initial]) showTab(initial);
831
1003
  // модели: подгружаем рекомендации один раз после первой загрузки
832
1004
  var _isEn = _lang === 'en';
833
1005
  </script>
@@ -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
- copy.splice(insertAt, 0, { role: 'system', content: text });
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 };
@@ -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
 
@@ -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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim();
36
+ const url = decodeUrl(hrefM ? hrefM[1] : '');
37
+ const snippet = snipM
38
+ ? snipM[1].replace(/<[^>]+>/g, '').replace(/&amp;/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.17",
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/server.js CHANGED
@@ -9,6 +9,7 @@ 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
- const cache = new LRUCache(500, 3600000, false, true); // 4th arg: semantic normalize ON
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) {
@@ -578,12 +620,19 @@ async function handleChatCompletion(req, res, body) {
578
620
  // не исключая fallback. coding→coding, reasoning→reasoning, chat/search→general.
579
621
  const cat = provider.category || 'general';
580
622
  if (taskCategory === 'coding' && cat === 'coding') weight *= 1.5;
623
+ else if (taskCategory === 'design' && cat === 'coding') weight *= 1.6;
581
624
  else if (taskCategory === 'reasoning' && cat === 'reasoning') weight *= 1.5;
582
625
  else if ((taskCategory === 'chat' || taskCategory === 'search') && cat === 'general') weight *= 1.2;
626
+ // Дизайн — это UI-код: визуальные рекомендации идут от coding-моделей.
627
+ else if (taskCategory === 'design' && cat === 'reasoning') weight *= 0.4;
583
628
  // Простой факт/поиск не должен «думать вслух» на reasoning-модели:
584
629
  // это дорого по лимитам и даёт размышления вместо краткого ответа.
585
630
  if ((taskCategory === 'chat' || taskCategory === 'search') && cat === 'reasoning') weight *= 0.15;
586
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;
587
636
  const dailyLimit = provider.dailyLimit || 0;
588
637
  // Провайдер, исчерпавший дневной лимит (попал в пул лишь как крайний
589
638
  // резерв, когда живы все выгорели), — сильно штрафуем, чтобы выбрать
@@ -634,13 +683,19 @@ async function handleChatCompletion(req, res, body) {
634
683
  const tLimit = (getStats().dailyUsage?.[targetProviderKey]?.[today]) || 0;
635
684
  const tCap = PROVIDERS[targetProviderKey].dailyLimit || 0;
636
685
  const targetBurned = tHealth?.status === 'ratelimited' || (tCap > 0 && tLimit >= tCap * 0.9);
637
- if (!targetBurned) {
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) {
638
693
  enabledProviders = [
639
694
  [targetProviderKey, PROVIDERS[targetProviderKey]],
640
695
  ...enabledProviders.filter(([k]) => k !== targetProviderKey),
641
696
  ];
642
697
  } else {
643
- 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 });
644
699
  }
645
700
  }
646
701