neuroalpha 0.5.15 → 0.5.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/admin.html CHANGED
@@ -7,6 +7,7 @@
7
7
  <title>Админ-консоль · НейроАльфа</title>
8
8
  <style>
9
9
  :root {
10
+ color-scheme: dark;
10
11
  --bg: #0b0e14;
11
12
  --panel: #131822;
12
13
  --panel2: #18202f;
@@ -20,7 +21,8 @@
20
21
  }
21
22
  * { box-sizing: border-box; }
22
23
  body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
23
- a { color: var(--accent); text-decoration: none; }
24
+ a, a:visited { color: var(--accent); text-decoration: none; }
25
+ a.brand, a.brand:visited { color: var(--text); }
24
26
  code { background: rgba(255,255,255,0.06); padding: 2px 6px; border-radius: 4px; font-size: 12.5px; color: #93c5fd; }
25
27
  .wrap { max-width: 1180px; margin: 0 auto; padding: 24px 24px 64px; }
26
28
  .top { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; padding-bottom: 16px; margin-bottom: 22px; border-bottom: 1px solid var(--line); }
@@ -50,9 +52,9 @@
50
52
  input:focus, select:focus { outline: none; border-color: var(--accent); }
51
53
  input.secret-input { -webkit-text-security: disc; }
52
54
  input.secret-input:focus { -webkit-text-security: none; }
53
- button, .btn { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 7px; padding: 8px 14px; font: inherit; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
54
- button:hover, .btn:hover { border-color: #3b4963; }
55
- button.primary, .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
55
+ button, .btn, a.btn, a.btn:visited { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 7px; padding: 8px 14px; font: inherit; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; text-decoration: none; }
56
+ button:hover, .btn:hover, a.btn:hover { border-color: #3b4963; color: var(--text); }
57
+ button.primary, .btn.primary, a.btn.primary, a.btn.primary:visited { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
56
58
  button:disabled { opacity: 0.45; cursor: default; }
57
59
  table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
58
60
  th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); }
package/public/app.js CHANGED
@@ -1612,21 +1612,101 @@ feed.addEventListener('click', async (e) => {
1612
1612
  // ======================================================================
1613
1613
  // МОДЕЛИ ИИ
1614
1614
  // ======================================================================
1615
- const PROTO_LABEL = { openai: 'OpenAI-совместимый', anthropic: 'Anthropic', gigachat: 'GigaChat', ollama: 'Ollama' };
1615
+ const PROTO_LABEL = {
1616
+ openai: 'OpenAI API',
1617
+ anthropic: 'Anthropic Claude',
1618
+ gigachat: 'Сбер GigaChat',
1619
+ ollama: 'Ollama (локально)',
1620
+ };
1616
1621
  const URL_HINT = {
1617
1622
  openai: 'https://…/v1/chat/completions',
1618
1623
  gigachat: 'https://gigachat.devices.sberbank.ru/api/v1/chat/completions',
1619
1624
  ollama: 'http://localhost:11434/v1/chat/completions',
1620
1625
  anthropic: 'https://api.anthropic.com/v1/messages',
1621
1626
  };
1627
+ const LLM_PRESETS = {
1628
+ openrouter: {
1629
+ name: 'OpenRouter',
1630
+ protocol: 'openai',
1631
+ baseUrl: 'https://openrouter.ai/api/v1/chat/completions',
1632
+ model: 'qwen/qwen3-235b-a22b',
1633
+ tools: true,
1634
+ hint: '<b>OpenRouter</b> — единый доступ к сотням моделей (Qwen, DeepSeek, Claude, GPT). Получите ключ в <code>openrouter.ai/keys</code>, вставьте ниже или после сохранения нажмите «🔍 Найти модели на сервере».',
1635
+ },
1636
+ deepseek: {
1637
+ name: 'DeepSeek',
1638
+ protocol: 'openai',
1639
+ baseUrl: 'https://api.deepseek.com/v1/chat/completions',
1640
+ model: 'deepseek-chat',
1641
+ tools: true,
1642
+ hint: '<b>DeepSeek API</b> — официальный API моделей DeepSeek V3 (<code>deepseek-chat</code>) и R1 (<code>deepseek-reasoner</code>). Ключ выпускается в <code>platform.deepseek.com/api_keys</code>.',
1643
+ },
1644
+ openai: {
1645
+ name: 'OpenAI',
1646
+ protocol: 'openai',
1647
+ baseUrl: 'https://api.openai.com/v1/chat/completions',
1648
+ model: 'gpt-4.1',
1649
+ tools: true,
1650
+ hint: '<b>OpenAI Platform</b> — модели <code>gpt-4.1</code>, <code>gpt-4.1-mini</code>, <code>o4-mini</code>. Ключ создаётся в <code>platform.openai.com/api-keys</code>.',
1651
+ },
1652
+ gigachat: {
1653
+ name: 'GigaChat (Сбер)',
1654
+ protocol: 'gigachat',
1655
+ baseUrl: 'https://gigachat.devices.sberbank.ru/api/v1/chat/completions',
1656
+ scope: 'GIGACHAT_API_PERS',
1657
+ model: 'GigaChat-2-Max',
1658
+ tools: true,
1659
+ hint: '<b>Сбер GigaChat</b> — работает в РФ без VPN, корневой сертификат Минцифры уже встроен в НейроАльфа. Создайте проект в <code>developers.sber.ru/studio</code> и скопируйте <b>Authorization key</b>.',
1660
+ },
1661
+ ollama: {
1662
+ name: 'Ollama (на этом ПК)',
1663
+ protocol: 'ollama',
1664
+ baseUrl: 'http://localhost:11434/v1/chat/completions',
1665
+ model: 'qwen2.5:14b',
1666
+ tools: true,
1667
+ hint: '<b>Ollama</b> — бесплатный запуск моделей прямо на вашем компьютере без интернета и ключей. Установите с <code>ollama.com</code>, выполните <code>ollama pull qwen2.5:14b</code> и нажмите «🔍 Найти модели на сервере».',
1668
+ },
1669
+ custom: {
1670
+ name: 'Свой ИИ-сервер',
1671
+ protocol: 'openai',
1672
+ baseUrl: 'http://localhost:8000/v1/chat/completions',
1673
+ model: '',
1674
+ tools: true,
1675
+ hint: '<b>Свой сервер (vLLM, SGLang, LM Studio, llama.cpp)</b> — укажите адрес совместимого эндпоинта <code>/v1/chat/completions</code> и при необходимости ключ авторизации.',
1676
+ },
1677
+ };
1622
1678
  const llmRemote = new Map();
1679
+ const llmRemoteFilter = new Map();
1680
+ let lastLlmTestResult = null;
1623
1681
  const modelKey = (p, m) => `${p}::${m}`;
1624
1682
 
1683
+ function shortEndpointHost(urlStr = '') {
1684
+ try {
1685
+ const u = new URL(urlStr);
1686
+ return u.host + (u.pathname && u.pathname !== '/' && !u.pathname.endsWith('/chat/completions') && !u.pathname.endsWith('/messages') ? u.pathname : '');
1687
+ } catch {
1688
+ return urlStr;
1689
+ }
1690
+ }
1691
+
1625
1692
  async function loadLlm() {
1626
1693
  state.llm = await api('/api/llm/providers');
1627
1694
  renderLlm();
1628
1695
  }
1629
1696
 
1697
+ async function selectActiveLlm(pid, mid) {
1698
+ try {
1699
+ state.llm = await post('/api/llm/select', { providerId: pid, model: mid });
1700
+ state.config.llm = state.llm.active;
1701
+ lastLlmTestResult = null;
1702
+ showLlmStatus();
1703
+ renderLlm();
1704
+ toast(`Активная модель Стратега: ${state.llm.active?.label || mid}`);
1705
+ } catch (err) {
1706
+ toast(err.message, 'error');
1707
+ }
1708
+ }
1709
+
1630
1710
  function renderModelMenu(filter = '') {
1631
1711
  if (!state.llm) return;
1632
1712
  const { providers, active } = state.llm;
@@ -1655,28 +1735,137 @@ function renderModelMenu(filter = '') {
1655
1735
  }
1656
1736
 
1657
1737
  function renderLlm() {
1738
+ if (!state.llm) return;
1658
1739
  const { providers, active } = state.llm;
1659
1740
  const btn = $('#chat-model-btn');
1660
1741
  const lbl = $('#chat-model-lbl');
1661
1742
  if (lbl) lbl.textContent = active?.model ? (active.label || active.model) : 'Модель не выбрана';
1662
- if (btn) btn.title = active?.enabled ? `Отвечает: ${active.provider} · ${active.label}` : 'Модель не выбрана или нет ключа — настройте в «Настройки → Модели ИИ»';
1743
+ if (btn) btn.title = active?.enabled ? `Отвечает: ${active.provider} · ${active.label}` : 'Модель не выбрана или нет ключа — настройте в «Модели ИИ»';
1663
1744
  renderModelMenu($('#model-search')?.value || '');
1664
- $('#llm-sum').textContent = `Модели ИИ для Стратега: ${active?.enabled ? `${active.provider} · ${active.label}` : 'не выбрана'}`;
1665
-
1666
- $('#llm-list').innerHTML = providers.map((p) => `<div class="llm-prov" data-p="${esc(p.id)}">
1667
- <div class="llm-head"><b>${esc(p.name)}</b><span class="grow"></span>
1668
- <button type="button" data-act="test">Проверить</button>
1669
- <button type="button" data-act="remote">Модели с сервера</button>
1670
- <button type="button" data-act="edit">Изменить</button>
1671
- <button type="button" data-act="del" class="icon" title="Удалить подключение">Удалить</button>
1745
+ $('#llm-sum').textContent = 'Модели ИИ для Стратега';
1746
+
1747
+ const bannerEl = $('#llm-active-banner');
1748
+ if (bannerEl) {
1749
+ const activeProv = providers.find((p) => p.id === active?.providerId);
1750
+ const activeMod = activeProv?.models.find((m) => m.id === active?.model);
1751
+ const toolsOn = activeMod ? activeMod.tools !== false && activeProv?.protocol !== 'anthropic' : true;
1752
+ if (active?.enabled) {
1753
+ bannerEl.innerHTML = `<div class="llm-active-hero">
1754
+ <div class="llm-active-main">
1755
+ <div class="llm-active-kicker"><span style="color:var(--green)">●</span> Сейчас отвечает ИИ-Стратег (в терминале, Telegram и MAX)</div>
1756
+ <div class="llm-active-title">
1757
+ <span>${esc(active.label || active.model)}</span>
1758
+ <span class="tag ok">${esc(active.provider)}</span>
1759
+ ${toolsOn
1760
+ ? '<span class="tag" title="Стратег видит ваши брокерские счета, живой прогноз, сделки и новости">⚡ Полный доступ к счетам и прогнозам</span>'
1761
+ : '<span class="tag mid" title="Инструменты отключены — модель работает только в режиме текстового собеседника">💬 Только беседа</span>'}
1762
+ </div>
1763
+ <div class="llm-active-sub">
1764
+ ${active.model !== active.label ? `<span>ID: <code>${esc(active.model)}</code></span> · ` : ''}
1765
+ <span>Сервер: <code>${esc(shortEndpointHost(activeProv?.baseUrl || ''))}</code></span>
1766
+ ${activeProv?.keySource ? ` · <span>Авторизация: <b>${esc(activeProv.keySource)}</b></span>` : ''}
1767
+ </div>
1768
+ ${lastLlmTestResult ? `<div class="llm-test-res ${lastLlmTestResult.ok ? 'ok' : 'err'}">${esc(lastLlmTestResult.text)}</div>` : ''}
1769
+ </div>
1770
+ <div class="row-gap" style="align-items:center">
1771
+ <button type="button" id="btn-llm-test-active" data-act="test-active">⚡ Проверить ответ ИИ</button>
1772
+ </div>
1773
+ </div>`;
1774
+ } else {
1775
+ bannerEl.innerHTML = `<div class="llm-active-hero off">
1776
+ <div class="llm-active-main">
1777
+ <div class="llm-active-kicker"><span style="color:var(--yellow)">▲</span> ИИ-Стратег сейчас не подключён</div>
1778
+ <div class="llm-active-title">Выберите рабочую модель в списке ниже или подключите свой сервис</div>
1779
+ <div class="llm-active-sub">Для работы чата и ответов бота в Telegram / MAX выберите модель с подключённым API-ключом.</div>
1780
+ </div>
1781
+ </div>`;
1782
+ }
1783
+ }
1784
+
1785
+ $('#llm-list').innerHTML = providers.map((p) => {
1786
+ const isProvActive = active?.providerId === p.id;
1787
+ const usable = p.hasKey || !p.needsKey;
1788
+ const isCloud = p.id === 'neuroalpha_cloud' || p.source === 'cloud';
1789
+ const keyBadge = p.needsKey
1790
+ ? (p.hasKey
1791
+ ? `<span class="tag ok" title="Ключ сохранён на сервере (${esc(p.keySource)})">✓ Ключ: ${esc(p.keySource || 'подключён')}</span>`
1792
+ : '<span class="tag no">⚠ Нужен API-ключ</span>')
1793
+ : `<span class="tag ok">${isCloud ? `✓ ${esc(p.keySource || 'Встроенный доступ')}` : '✓ Ключ не требуется'}</span>`;
1794
+ const remoteList = llmRemote.get(p.id);
1795
+ const rFilter = (llmRemoteFilter.get(p.id) || '').trim().toLowerCase();
1796
+ const filteredRemote = Array.isArray(remoteList)
1797
+ ? remoteList.filter((m) => !rFilter || m.id.toLowerCase().includes(rFilter))
1798
+ : [];
1799
+
1800
+ return `<div class="llm-prov-card${isProvActive ? ' is-active' : ''}" data-p="${esc(p.id)}">
1801
+ <div class="llm-head">
1802
+ <div class="llm-title">
1803
+ <span>${esc(p.name)}</span>
1804
+ <span class="tag">${esc(PROTO_LABEL[p.protocol] || p.protocol)}</span>
1805
+ ${keyBadge}
1806
+ </div>
1807
+ <span class="grow"></span>
1808
+ <button type="button" data-act="test" title="Отправить тестовый запрос к этому подключению">⚡ Проверить связь</button>
1809
+ <button type="button" data-act="remote" title="Запросить у сервера список всех доступных моделей">🔍 Модели на сервере</button>
1810
+ <button type="button" data-act="edit" title="Изменить адрес, ключ или параметры подключения">⚙ Настроить</button>
1811
+ ${isCloud ? '' : '<button type="button" data-act="del" class="icon" style="color:var(--muted)" title="Удалить подключение">🗑</button>'}
1812
+ </div>
1813
+ <div class="llm-meta">
1814
+ <span>Адрес: <code title="${esc(p.baseUrl)}">${esc(shortEndpointHost(p.baseUrl))}</code></span>
1815
+ ${p.protocol === 'gigachat' ? `<span>· Scope: <code>${esc(p.scope || 'GIGACHAT_API_PERS')}</code> · Сертификат Минцифры: <b>${p.caFile ? esc(p.caFile) : 'встроен ✓'}</b></span>` : ''}
1816
+ </div>
1817
+
1818
+ <div class="llm-models">
1819
+ ${p.models.map((m) => {
1820
+ const on = active?.providerId === p.id && active.model === m.id;
1821
+ const toolsEnabled = Boolean(m.tools) && p.protocol !== 'anthropic';
1822
+ return `<div class="llm-model-row${on ? ' on' : ''}">
1823
+ <button type="button" class="llm-model-pick" data-act="select" data-m="${esc(m.id)}" ${usable ? '' : 'disabled'} title="${on ? 'Эта модель сейчас отвечает за Стратега' : (usable ? 'Нажмите, чтобы сделать эту модель активной для Стратега' : 'Сначала укажите API-ключ в настройках подключения')}">
1824
+ <span class="llm-radio"></span>
1825
+ <span class="llm-model-info">
1826
+ <b>${esc(m.label)}</b>
1827
+ ${m.label !== m.id ? `<code class="muted small">${esc(m.id)}</code>` : ''}
1828
+ ${on ? '<span class="tag ok" style="font-size:11px">● Активна</span>' : ''}
1829
+ </span>
1830
+ </button>
1831
+ <div class="llm-model-actions">
1832
+ ${p.protocol === 'anthropic'
1833
+ ? '<span class="muted small" title="Протокол Anthropic подключён в режиме текстовой беседы">💬 только беседа</span>'
1834
+ : `<label class="llm-tools-lbl${toolsEnabled ? ' on' : ''}" title="Разрешить модели запрашивать ваши брокерские счета, живой прогноз, сделки и новости Мосбиржи">
1835
+ <input type="checkbox" data-act="tools" data-m="${esc(m.id)}"${toolsEnabled ? ' checked' : ''}>
1836
+ <span>${toolsEnabled ? '⚡ Доступ к терминалу' : '💬 Только беседа'}</span>
1837
+ </label>`}
1838
+ ${!on && usable ? `<button type="button" class="small" data-act="select" data-m="${esc(m.id)}">Выбрать</button>` : ''}
1839
+ <button type="button" class="icon" data-act="rm" data-m="${esc(m.id)}" title="${on ? 'Эта модель сейчас выбрана активной' : 'Убрать модель из списка'}"${on || p.models.length <= 1 && isCloud ? ' disabled' : ''}>✕</button>
1840
+ </div>
1841
+ </div>`;
1842
+ }).join('') || '<div class="muted small" style="padding:6px 2px">В этом подключении пока нет добавленных моделей — впишите ID модели ниже или нажмите «🔍 Модели на сервере».</div>'}
1843
+ </div>
1844
+
1845
+ <div class="llm-card-foot">
1846
+ <div class="llm-quick-add">
1847
+ <input type="text" data-role="quick-model-in" placeholder="Добавить модель по ID (например, deepseek-reasoner)" autocomplete="off">
1848
+ <button type="button" data-act="quick-add">+ Добавить модель</button>
1849
+ </div>
1850
+ <span class="muted small">${usable ? 'Нажмите на модель выше, чтобы переключить Стратега на неё' : 'Нажмите «⚙ Настроить», чтобы указать API-ключ'}</span>
1672
1851
  </div>
1673
- <div class="llm-meta">${esc(PROTO_LABEL[p.protocol] || p.protocol)} · ${esc(p.baseUrl)} · ${p.needsKey ? (p.hasKey ? `ключ есть (${esc(p.keySource)})` : '<span class="nokey">ключа нет</span>') : 'ключ не нужен'}${p.protocol === 'gigachat' ? (p.caFile ? ` · сертификат: ${esc(p.caFile)}` : ' · <span class="nokey">сертификат НУЦ не указан</span>') : ''}</div>
1674
- <div class="llm-models">${p.models.map((m) => {
1675
- const on = active?.providerId === p.id && active.model === m.id;
1676
- return `<div class="m${on ? ' on' : ''}"><b>${esc(m.label)}</b><span class="muted small">${m.label === m.id ? '' : esc(m.id)}</span><span class="grow"></span>${p.protocol === 'anthropic' ? '<span class="muted small">только беседа</span>' : `<label><input type="checkbox" data-act="tools" data-m="${esc(m.id)}"${m.tools ? ' checked' : ''}>инструменты</label>`}<button type="button" class="icon" data-act="rm" data-m="${esc(m.id)}" title="${on ? 'Эта модель сейчас выбрана' : 'Убрать модель из списка'}"${on ? ' disabled' : ''}>✕</button></div>`;
1677
- }).join('') || '<div class="muted small">Моделей нет — нажмите «Модели с сервера» или добавьте вручную.</div>'}</div>
1678
- ${llmRemote.has(p.id) ? `<div class="llm-remote">${llmRemote.get(p.id).length ? llmRemote.get(p.id).map((m) => `<button type="button" data-act="add" data-m="${esc(m.id)}"${m.added ? ' disabled' : ''}>${m.added ? '✓' : '+'} ${esc(m.id)}</button>`).join('') : '<span class="muted small">Сервер не вернул моделей</span>'}</div>` : ''}
1679
- </div>`).join('') || '<p class="muted">Подключений нет — добавьте своё ниже.</p>';
1852
+
1853
+ ${llmRemote.has(p.id) ? `<div class="llm-remote-box">
1854
+ <div class="row-gap" style="align-items:center;justify-content:space-between;flex-wrap:wrap">
1855
+ <b class="small">Найдено на сервере: ${remoteList.length} мод. (нажмите, чтобы добавить в список)</b>
1856
+ <div class="row-gap" style="align-items:center">
1857
+ ${remoteList.length > 6 ? `<input type="search" data-act="remote-filter" value="${esc(llmRemoteFilter.get(p.id) || '')}" placeholder="Фильтр по названию…" style="padding:3px 8px;font-size:12px;width:180px">` : ''}
1858
+ <button type="button" class="small icon" data-act="close-remote" title="Скрыть список серверных моделей">✕</button>
1859
+ </div>
1860
+ </div>
1861
+ <div class="llm-remote">
1862
+ ${filteredRemote.length
1863
+ ? filteredRemote.slice(0, 120).map((m) => `<button type="button" data-act="add" data-m="${esc(m.id)}"${m.added ? ' disabled' : ''}>${m.added ? '✓ ' : '+ '}${esc(m.id)}</button>`).join('')
1864
+ : '<span class="muted small">По вашему фильтру модели не найдены</span>'}
1865
+ </div>
1866
+ </div>` : ''}
1867
+ </div>`;
1868
+ }).join('') || '<p class="muted">Подключений нет — нажмите «+ Подключить ИИ-сервис» выше.</p>';
1680
1869
  }
1681
1870
 
1682
1871
  async function llmModels(pid, fn) {
@@ -1710,12 +1899,7 @@ $('#model-menu-list')?.addEventListener('click', async (e) => {
1710
1899
  if (!b || b.disabled) return;
1711
1900
  const [pid, ...rest] = b.dataset.mval.split('::');
1712
1901
  closeModelMenu();
1713
- try {
1714
- state.llm = await post('/api/llm/select', { providerId: pid, model: rest.join('::') });
1715
- state.config.llm = state.llm.active;
1716
- showLlmStatus();
1717
- } catch (err) { toast(err.message, 'error'); }
1718
- renderLlm();
1902
+ await selectActiveLlm(pid, rest.join('::'));
1719
1903
  });
1720
1904
 
1721
1905
  $('#model-manage-btn')?.addEventListener('click', () => {
@@ -1729,22 +1913,74 @@ document.addEventListener('click', (e) => {
1729
1913
  if (!e.target.closest('#mode-picker')) closeModeMenu();
1730
1914
  });
1731
1915
 
1916
+ $('#llm-active-banner')?.addEventListener('click', async (e) => {
1917
+ const b = e.target.closest('button[data-act="test-active"]');
1918
+ if (!b || !state.llm?.active?.providerId) return;
1919
+ const { providerId, model, provider, label } = state.llm.active;
1920
+ const old = b.textContent;
1921
+ b.disabled = true;
1922
+ b.textContent = 'Проверяю ответ…';
1923
+ try {
1924
+ const r = await post('/api/llm/test', { providerId, model });
1925
+ if (r.ok) {
1926
+ lastLlmTestResult = { ok: true, text: `✓ Ответ от «${provider} · ${label || model}» за ${fmtNum(r.ms / 1000, 1)} с: «${r.text}»` };
1927
+ toast(lastLlmTestResult.text);
1928
+ } else {
1929
+ lastLlmTestResult = { ok: false, text: `✕ Ошибка «${provider}»: ${r.error}` };
1930
+ toast(lastLlmTestResult.text, 'error');
1931
+ }
1932
+ renderLlm();
1933
+ } catch (err) {
1934
+ lastLlmTestResult = { ok: false, text: `✕ ${err.message}` };
1935
+ toast(err.message, 'error');
1936
+ renderLlm();
1937
+ } finally {
1938
+ if (b.isConnected) { b.disabled = false; b.textContent = old; }
1939
+ }
1940
+ });
1941
+
1942
+ $('#btn-llm-open-add')?.addEventListener('click', () => {
1943
+ stopEdit();
1944
+ const box = $('#llm-add-box');
1945
+ if (box) {
1946
+ box.open = true;
1947
+ box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1948
+ }
1949
+ });
1950
+
1732
1951
  $('#llm-list').addEventListener('click', async (e) => {
1733
1952
  const b = e.target.closest('button[data-act]');
1734
1953
  if (!b) return;
1735
- const pid = b.closest('[data-p]').dataset.p;
1954
+ const card = b.closest('[data-p]');
1955
+ if (!card) return;
1956
+ const pid = card.dataset.p;
1736
1957
  const p = state.llm.providers.find((x) => x.id === pid);
1958
+ if (!p) return;
1737
1959
  const old = b.textContent;
1738
1960
  try {
1739
1961
  switch (b.dataset.act) {
1962
+ case 'select': {
1963
+ const mid = b.dataset.m;
1964
+ if (mid) await selectActiveLlm(pid, mid);
1965
+ break;
1966
+ }
1740
1967
  case 'test': {
1741
1968
  const model = state.llm.active?.providerId === pid ? state.llm.active.model : p.models[0]?.id;
1742
- if (!model) { toast('Сначала добавьте модель', 'error'); break; }
1969
+ if (!model) { toast('Сначала добавьте модель в это подключение', 'error'); break; }
1743
1970
  b.disabled = true;
1744
1971
  b.textContent = 'Проверяю…';
1745
1972
  const r = await post('/api/llm/test', { providerId: pid, model });
1746
- if (r.ok) toast(`${p.name} · ${model}: «${r.text}» за ${fmtNum(r.ms / 1000, 1)} с`);
1747
- else toast(`${p.name}: ${r.error}`, 'error');
1973
+ if (r.ok) {
1974
+ const msg = `✓ ${p.name} · ${model}: ответ «${r.text}» за ${fmtNum(r.ms / 1000, 1)} с`;
1975
+ if (state.llm.active?.providerId === pid) lastLlmTestResult = { ok: true, text: msg };
1976
+ toast(msg);
1977
+ renderLlm();
1978
+ } else {
1979
+ const msg = `✕ ${p.name}: ${r.error}`;
1980
+ if (state.llm.active?.providerId === pid) lastLlmTestResult = { ok: false, text: msg };
1981
+ toast(msg, 'error');
1982
+ renderLlm();
1983
+ }
1748
1984
  break;
1749
1985
  }
1750
1986
  case 'remote': {
@@ -1754,23 +1990,77 @@ $('#llm-list').addEventListener('click', async (e) => {
1754
1990
  renderLlm();
1755
1991
  break;
1756
1992
  }
1993
+ case 'close-remote': {
1994
+ llmRemote.delete(pid);
1995
+ llmRemoteFilter.delete(pid);
1996
+ renderLlm();
1997
+ break;
1998
+ }
1757
1999
  case 'add': {
1758
- await llmModels(pid, (ms) => [...ms, { id: b.dataset.m, label: b.dataset.m, tools: p.protocol !== 'anthropic' }]);
2000
+ const mid = b.dataset.m;
2001
+ await llmModels(pid, (ms) => [...ms, { id: mid, label: mid, tools: p.protocol !== 'anthropic' }]);
1759
2002
  const list = llmRemote.get(pid);
1760
- if (list) list.forEach((m) => { if (m.id === b.dataset.m) m.added = true; });
1761
- renderLlm();
2003
+ if (list) list.forEach((m) => { if (m.id === mid) m.added = true; });
2004
+ if (!state.llm.active?.enabled && (p.hasKey || !p.needsKey)) {
2005
+ await selectActiveLlm(pid, mid);
2006
+ } else {
2007
+ renderLlm();
2008
+ }
1762
2009
  break;
1763
2010
  }
1764
- case 'rm': await llmModels(pid, (ms) => ms.filter((m) => m.id !== b.dataset.m)); break;
1765
- case 'edit': startEdit(p); break;
2011
+ case 'quick-add': {
2012
+ const inp = card.querySelector('input[data-role="quick-model-in"]');
2013
+ const mid = String(inp?.value || '').trim();
2014
+ if (!mid) { toast('Введите идентификатор модели (например, deepseek-chat)', 'error'); inp?.focus(); break; }
2015
+ await llmModels(pid, (ms) => [...ms, { id: mid, label: mid, tools: p.protocol !== 'anthropic' }]);
2016
+ toast(`Модель «${mid}» добавлена в «${p.name}»`);
2017
+ break;
2018
+ }
2019
+ case 'rm':
2020
+ await llmModels(pid, (ms) => ms.filter((m) => m.id !== b.dataset.m));
2021
+ break;
2022
+ case 'edit':
2023
+ startEdit(p);
2024
+ break;
1766
2025
  case 'del':
1767
- if (confirm(`Удалить подключение «${p.name}»?`)) { llmRemote.delete(pid); state.llm = await api(`/api/llm/providers/${encodeURIComponent(pid)}`, { method: 'DELETE' }); renderLlm(); }
2026
+ if (confirm(`Удалить подключение «${p.name}»?`)) {
2027
+ llmRemote.delete(pid);
2028
+ state.llm = await api(`/api/llm/providers/${encodeURIComponent(pid)}`, { method: 'DELETE' });
2029
+ renderLlm();
2030
+ }
1768
2031
  break;
1769
2032
  }
1770
2033
  } catch (err) { toast(err.message, 'error'); } finally {
1771
- if (b.isConnected) { b.disabled = false; b.textContent = old; }
2034
+ if (b.isConnected && b.dataset.act !== 'select') { b.disabled = false; b.textContent = old; }
2035
+ }
2036
+ });
2037
+
2038
+ $('#llm-list').addEventListener('keydown', (e) => {
2039
+ if (e.key === 'Enter' && e.target.matches('input[data-role="quick-model-in"]')) {
2040
+ e.preventDefault();
2041
+ e.target.closest('[data-p]')?.querySelector('button[data-act="quick-add"]')?.click();
2042
+ }
2043
+ });
2044
+
2045
+ $('#llm-list').addEventListener('input', (e) => {
2046
+ if (e.target.matches('input[data-act="remote-filter"]')) {
2047
+ const card = e.target.closest('[data-p]');
2048
+ if (!card) return;
2049
+ const pid = card.dataset.p;
2050
+ const val = e.target.value;
2051
+ llmRemoteFilter.set(pid, val);
2052
+ const remoteList = llmRemote.get(pid) || [];
2053
+ const q = val.trim().toLowerCase();
2054
+ const filtered = remoteList.filter((m) => !q || m.id.toLowerCase().includes(q));
2055
+ const box = card.querySelector('.llm-remote');
2056
+ if (box) {
2057
+ box.innerHTML = filtered.length
2058
+ ? filtered.slice(0, 120).map((m) => `<button type="button" data-act="add" data-m="${esc(m.id)}"${m.added ? ' disabled' : ''}>${m.added ? '✓ ' : '+ '}${esc(m.id)}</button>`).join('')
2059
+ : '<span class="muted small">По вашему фильтру модели не найдены</span>';
2060
+ }
1772
2061
  }
1773
2062
  });
2063
+
1774
2064
  $('#llm-list').addEventListener('change', async (e) => {
1775
2065
  const c = e.target.closest('input[data-act="tools"]');
1776
2066
  if (!c) return;
@@ -1784,16 +2074,42 @@ function syncAddForm() {
1784
2074
  const proto = addForm.elements.protocol.value;
1785
2075
  addForm.elements.baseUrl.placeholder = URL_HINT[proto];
1786
2076
  addForm.elements.baseUrl.required = proto === 'openai' && !addForm.elements.baseUrl.value;
1787
- $('#llm-key-label').textContent = proto === 'gigachat' ? 'Ключ авторизации (Authorization key)' : 'Ключ (API key)';
1788
- addForm.elements.apiKey.placeholder = editingPid ? 'не меняется — оставьте пустым' : '';
2077
+ $('#llm-key-label').textContent = proto === 'gigachat' ? 'Ключ авторизации (Authorization key)' : 'Секретный ключ (API Key)';
2078
+ addForm.elements.apiKey.placeholder = editingPid ? 'Оставьте пустым, чтобы сохранить текущий ключ' : 'Вставьте ключ доступа (хранится зашифрованно)';
1789
2079
  addForm.querySelector('[data-for=key]').hidden = proto === 'ollama';
1790
2080
  addForm.querySelector('[data-for=scope]').hidden = proto !== 'gigachat';
1791
2081
  addForm.querySelector('[data-for=cert]').hidden = proto !== 'gigachat';
1792
2082
  addForm.querySelector('[data-for=clearkey]').hidden = proto === 'ollama' || !editingPid;
1793
2083
  addForm.elements.tools.disabled = proto === 'anthropic';
2084
+ const mLbl = $('#llm-model-field-lbl');
2085
+ if (mLbl) mLbl.textContent = editingPid ? 'Добавить ещё модель (необязательно)' : 'Основная модель (ID)';
1794
2086
  }
1795
2087
  addForm.elements.protocol.addEventListener('change', syncAddForm);
1796
2088
  syncAddForm();
2089
+
2090
+ $('#llm-presets')?.addEventListener('click', (e) => {
2091
+ const btn = e.target.closest('button[data-preset]');
2092
+ if (!btn) return;
2093
+ const preset = LLM_PRESETS[btn.dataset.preset];
2094
+ if (!preset) return;
2095
+ $$('#llm-presets .llm-preset-btn').forEach((b) => b.classList.toggle('active', b === btn));
2096
+ const f = addForm.elements;
2097
+ f.name.value = preset.name;
2098
+ f.protocol.value = preset.protocol;
2099
+ f.baseUrl.value = preset.baseUrl;
2100
+ if (preset.scope && f.scope) f.scope.value = preset.scope;
2101
+ f.model.value = preset.model || '';
2102
+ f.tools.checked = preset.tools !== false && preset.protocol !== 'anthropic';
2103
+ syncAddForm();
2104
+ const hintEl = $('#llm-preset-hint');
2105
+ if (hintEl) {
2106
+ hintEl.innerHTML = preset.hint || '';
2107
+ hintEl.hidden = !preset.hint;
2108
+ }
2109
+ if (preset.protocol !== 'ollama') f.apiKey.focus();
2110
+ else f.model.focus();
2111
+ });
2112
+
1797
2113
  function startEdit(p) {
1798
2114
  editingPid = p.id;
1799
2115
  const f = addForm.elements;
@@ -1802,47 +2118,76 @@ function startEdit(p) {
1802
2118
  f.protocol.value = p.protocol;
1803
2119
  f.baseUrl.value = p.baseUrl;
1804
2120
  f.apiKey.value = '';
1805
- f.scope.value = p.scope || '';
2121
+ f.scope.value = p.scope || 'GIGACHAT_API_PERS';
1806
2122
  f.caFile.value = p.caFile || '';
1807
2123
  f.model.value = '';
1808
2124
  f.tools.checked = p.models[0]?.tools !== false && p.protocol !== 'anthropic';
1809
2125
  f.clearKey.checked = false;
2126
+ if ($('#llm-presets-wrap')) $('#llm-presets-wrap').hidden = true;
1810
2127
  $('#llm-add-box').open = true;
1811
- $('#llm-add-sum').textContent = `Изменить подключение «${p.name}»`;
1812
- $('#llm-add-btn').textContent = 'Сохранить';
2128
+ $('#llm-add-sum').textContent = `⚙ Настройка подключения «${p.name}»`;
2129
+ $('#llm-add-btn').textContent = 'Сохранить изменения';
1813
2130
  $('#llm-cancel').hidden = false;
1814
- addForm.scrollIntoView({ block: 'nearest' });
2131
+ $('#llm-add-box').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1815
2132
  syncAddForm();
1816
2133
  }
1817
2134
  function stopEdit() {
1818
2135
  editingPid = null;
1819
2136
  addForm.reset();
1820
2137
  addForm.elements.pid.value = '';
1821
- $('#llm-add-sum').textContent = 'Добавить подключение';
1822
- $('#llm-add-btn').textContent = 'Добавить';
2138
+ $$('#llm-presets .llm-preset-btn').forEach((b) => b.classList.remove('active'));
2139
+ if ($('#llm-preset-hint')) { $('#llm-preset-hint').hidden = true; $('#llm-preset-hint').innerHTML = ''; }
2140
+ if ($('#llm-presets-wrap')) $('#llm-presets-wrap').hidden = false;
2141
+ $('#llm-add-sum').textContent = '+ Подключить новый сервис или локальную модель ИИ';
2142
+ $('#llm-add-btn').textContent = 'Сохранить подключение';
1823
2143
  $('#llm-cancel').hidden = true;
1824
2144
  syncAddForm();
1825
2145
  }
1826
- $('#llm-cancel').addEventListener('click', stopEdit);
2146
+ $('#llm-cancel').addEventListener('click', () => {
2147
+ stopEdit();
2148
+ $('#llm-add-box').open = false;
2149
+ });
1827
2150
  addForm.addEventListener('submit', async (e) => {
1828
2151
  e.preventDefault();
1829
2152
  const f = addForm.elements;
1830
2153
  const model = f.model.value.trim();
2154
+ const existingProv = editingPid ? state.llm?.providers?.find((x) => x.id === editingPid) : null;
2155
+ let nextModels;
2156
+ if (editingPid) {
2157
+ if (model) {
2158
+ const prev = existingProv?.models ? existingProv.models.map((m) => ({ ...m })) : [];
2159
+ if (!prev.some((m) => m.id === model)) {
2160
+ prev.push({ id: model, label: model, tools: f.tools.checked && f.protocol.value !== 'anthropic' });
2161
+ }
2162
+ nextModels = prev;
2163
+ }
2164
+ } else {
2165
+ nextModels = model ? [{ id: model, label: model, tools: f.tools.checked && f.protocol.value !== 'anthropic' }] : [];
2166
+ }
1831
2167
  const body = {
1832
- name: f.name.value, protocol: f.protocol.value, baseUrl: f.baseUrl.value, scope: f.scope.value, caFile: f.caFile.value,
2168
+ name: f.name.value,
2169
+ protocol: f.protocol.value,
2170
+ baseUrl: f.baseUrl.value,
2171
+ scope: f.scope.value,
2172
+ caFile: f.caFile.value,
1833
2173
  ...(f.apiKey.value.trim() ? { apiKey: f.apiKey.value } : {}),
1834
2174
  ...(f.clearKey.checked ? { clearKey: true } : {}),
1835
- // при редактировании пустое поле модели — не трогать список моделей
1836
- ...(model || !editingPid ? { models: model ? [{ id: model, label: model, tools: f.tools.checked && f.protocol.value !== 'anthropic' }] : [] } : {}),
2175
+ ...(nextModels !== undefined ? { models: nextModels } : {}),
1837
2176
  };
1838
2177
  const wasEdit = !!editingPid;
1839
2178
  try {
1840
2179
  state.llm = wasEdit
1841
2180
  ? await api(`/api/llm/providers/${encodeURIComponent(editingPid)}`, { method: 'PUT', body })
1842
2181
  : await post('/api/llm/providers', body);
2182
+ const createdProv = !wasEdit ? state.llm.provider : null;
1843
2183
  stopEdit();
1844
- renderLlm();
1845
- toast(wasEdit ? 'Подключение сохранено' : 'Подключение добавлено. Нажмите «Проверить», чтобы убедиться, что оно работает.');
2184
+ $('#llm-add-box').open = false;
2185
+ if (createdProv && createdProv.models?.length && (createdProv.hasKey || !createdProv.needsKey)) {
2186
+ await selectActiveLlm(createdProv.id, createdProv.models[0].id);
2187
+ } else {
2188
+ renderLlm();
2189
+ toast(wasEdit ? 'Настройки подключения сохранены' : 'Подключение добавлено');
2190
+ }
1846
2191
  } catch (err) { toast(err.message, 'error'); }
1847
2192
  });
1848
2193
 
@@ -3245,7 +3590,7 @@ async function renderCloudPane() {
3245
3590
  <div class="hint">Управление пользователями, тарифами, приёмом платежей ЮKassa, промокодами, VIP-токенами, OAuth-провайдерами и юридическими реквизитами оферты вынесено в отдельное приложение.</div>
3246
3591
  </div>
3247
3592
  <div class="row-gap" style="flex-wrap:wrap">
3248
- <a class="btn primary" href="/admin" style="padding:7px 14px;border-radius:7px;text-decoration:none">Открыть админ-консоль (/admin) →</a>
3593
+ <a class="btn primary" href="/admin">Открыть админ-консоль (/admin) →</a>
3249
3594
  </div>
3250
3595
  </div>`;
3251
3596
  } else {
@@ -3303,7 +3648,7 @@ async function renderCloudPane() {
3303
3648
  <button type="button" id="btn-promo-redeem">Активировать код / VIP</button>
3304
3649
  <button type="button" class="primary" data-checkout="investor">Тариф «Инвестор» · 3 900 ₽</button>
3305
3650
  <button type="button" class="primary" data-checkout="pro">Тариф «Профи» · 9 900 ₽</button>
3306
- <a class="btn" href="/landing#pricing" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Согласовать VIP →</a>
3651
+ <a class="btn" href="/landing#pricing">Согласовать VIP →</a>
3307
3652
  </div>
3308
3653
  </div>
3309
3654
  <div class="srow sblock">
@@ -3316,10 +3661,10 @@ async function renderCloudPane() {
3316
3661
  </div>
3317
3662
  </div>
3318
3663
  <div class="row-gap" style="flex-wrap:wrap">
3319
- ${tgUrl ? `<a class="btn" href="${esc(tgUrl)}" target="_blank" rel="noopener" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px">Открыть Telegram</a>` : ''}
3320
- ${maxUrl ? `<a class="btn" href="${esc(maxUrl)}" target="_blank" rel="noopener" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px">Открыть MAX</a>` : ''}
3664
+ ${tgUrl ? `<a class="btn" href="${esc(tgUrl)}" target="_blank" rel="noopener">Открыть Telegram</a>` : ''}
3665
+ ${maxUrl ? `<a class="btn" href="${esc(maxUrl)}" target="_blank" rel="noopener">Открыть MAX</a>` : ''}
3321
3666
  <button type="button" id="btn-notify-test">Тест уведомления</button>
3322
- <a class="btn" href="/legal" target="_blank" style="padding:5px 10px;border:1px solid var(--line);border-radius:6px;text-decoration:none">Договор-оферта →</a>
3667
+ <a class="btn" href="/legal" target="_blank">Договор-оферта →</a>
3323
3668
  </div>
3324
3669
  </div>
3325
3670
  ${adminLinkHtml}
package/public/index.html CHANGED
@@ -246,36 +246,61 @@
246
246
  </section>
247
247
 
248
248
  <section class="ssec" data-sec="llm" id="llm-box" hidden>
249
- <div class="stitle" id="llm-sum">Модели ИИ для Стратега</div>
249
+ <div class="stitle bar">
250
+ <span id="llm-sum">Модели ИИ для Стратега</span>
251
+ <div class="row-gap" style="align-items:center">
252
+ <button type="button" id="btn-llm-open-add" class="primary">+ Подключить ИИ-сервис</button>
253
+ </div>
254
+ </div>
250
255
  <div class="scard spadded">
251
- <p class="muted small" style="margin-top:0">Выберите модель в шапке чата. Ключи хранятся только в базе на этом компьютере и в интерфейсе не показываются.</p>
252
- <div id="llm-list"></div>
253
- <details id="llm-add-box">
254
- <summary id="llm-add-sum">Добавить подключение</summary>
255
- <form id="llm-add" autocomplete="off">
256
- <input type="hidden" name="pid">
257
- <label><span>Название</span><input name="name" required placeholder="Например, Мой сервер"></label>
258
- <label><span>Протокол</span>
259
- <select name="protocol">
260
- <option value="openai">OpenAI-совместимый</option>
261
- <option value="gigachat">GigaChat</option>
262
- <option value="ollama">Ollama (на этом компьютере)</option>
263
- <option value="anthropic">Anthropic (только беседа)</option>
264
- </select>
265
- </label>
266
- <label><span>Адрес</span><input name="baseUrl" placeholder="https://…/v1/chat/completions"></label>
267
- <label data-for="key"><span id="llm-key-label">Ключ</span><input name="apiKey" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" data-1p-ignore="true"></label>
268
- <label data-for="scope" hidden><span>Область доступа (scope)</span><input name="scope" placeholder="GIGACHAT_API_PERS"></label>
269
- <label data-for="cert" hidden><span>Сертификат НУЦ Минцифры (путь к .pem, для GigaChat)</span><input name="caFile" placeholder="путь к russian_trusted_root_ca.pem"></label>
270
- <label data-for="model"><span>Модель (идентификатор на сервере)</span><input name="model" placeholder="например, qwen/Qwen3-8B"></label>
271
- <label class="check" data-for="tools"><span>Модель умеет пользоваться инструментами</span><input name="tools" type="checkbox" checked></label>
272
- <label class="check" data-for="clearkey" hidden><span>Убрать сохранённый ключ</span><input name="clearKey" type="checkbox"></label>
273
- <div class="row-gap">
274
- <button class="primary" type="submit" id="llm-add-btn">Добавить</button>
275
- <button type="button" id="llm-cancel" hidden>Отмена</button>
256
+ <div id="llm-active-banner"></div>
257
+ <div id="llm-form-slot">
258
+ <details id="llm-add-box" class="llm-form-card">
259
+ <summary id="llm-add-sum">+ Подключить новый сервис или локальную модель ИИ</summary>
260
+ <div class="llm-presets-wrap" id="llm-presets-wrap">
261
+ <div class="muted small" style="margin-bottom:6px">Выберите готовый шаблон для быстрой настройки в 1 клик или укажите свой сервер:</div>
262
+ <div class="llm-presets" id="llm-presets">
263
+ <button type="button" class="llm-preset-btn" data-preset="openrouter">🌐 OpenRouter<span>Qwen, Claude, GPT, DeepSeek</span></button>
264
+ <button type="button" class="llm-preset-btn" data-preset="deepseek">🐳 DeepSeek API<span>deepseek-chat / reasoner</span></button>
265
+ <button type="button" class="llm-preset-btn" data-preset="openai">🤖 OpenAI<span>gpt-4.1 / o4-mini</span></button>
266
+ <button type="button" class="llm-preset-btn" data-preset="gigachat">🟢 GigaChat (Сбер)<span>GigaChat-2-Max / Pro</span></button>
267
+ <button type="button" class="llm-preset-btn" data-preset="ollama">🖥 Ollama (на ПК)<span>Локально без API-ключа</span></button>
268
+ <button type="button" class="llm-preset-btn" data-preset="custom">⚙ Свой сервер<span>vLLM / LM Studio / SGLang</span></button>
269
+ </div>
270
+ <div id="llm-preset-hint" class="llm-preset-hint" hidden></div>
276
271
  </div>
277
- </form>
278
- </details>
272
+ <form id="llm-add" autocomplete="off" style="margin-top:12px">
273
+ <input type="hidden" name="pid">
274
+ <label><span>Название подключения</span><input name="name" required placeholder="Например: Корпоративный vLLM или OpenRouter"></label>
275
+ <label><span>Тип подключения (протокол)</span>
276
+ <select name="protocol">
277
+ <option value="openai">OpenAI-совместимый API (стандарт для большинства сервисов)</option>
278
+ <option value="gigachat">Сбер GigaChat API</option>
279
+ <option value="ollama">Ollama (локально на этом компьютере)</option>
280
+ <option value="anthropic">Anthropic Claude API (только текстовая беседа)</option>
281
+ </select>
282
+ </label>
283
+ <label><span>Адрес сервера (URL)</span><input name="baseUrl" placeholder="https://…/v1/chat/completions"></label>
284
+ <label data-for="key"><span id="llm-key-label">Секретный ключ (API Key)</span><input name="apiKey" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" data-1p-ignore="true" placeholder="Вставьте API-ключ (хранится зашифрованно)"></label>
285
+ <label data-for="scope" hidden><span>Версия API GigaChat (scope)</span>
286
+ <select name="scope">
287
+ <option value="GIGACHAT_API_PERS">GIGACHAT_API_PERS — для физлиц</option>
288
+ <option value="GIGACHAT_API_B2B">GIGACHAT_API_B2B — для ИП и юрлиц (пакеты)</option>
289
+ <option value="GIGACHAT_API_CORP">GIGACHAT_API_CORP — для юрлиц (pay-as-you-go)</option>
290
+ </select>
291
+ </label>
292
+ <label data-for="cert" hidden><span>Сертификат НУЦ Минцифры (.pem)</span><input name="caFile" placeholder="необязательно (встроен автоматически) или путь к .pem"></label>
293
+ <label data-for="model"><span id="llm-model-field-lbl">Основная модель (ID)</span><input name="model" placeholder="например, Qwen/Qwen3-32B или deepseek-chat"></label>
294
+ <label class="check" data-for="tools"><span>Разрешить модели работать со счетами, прогнозами и новостями (вызов инструментов)</span><input name="tools" type="checkbox" class="switch" checked></label>
295
+ <label class="check" data-for="clearkey" hidden><span>Удалить ранее сохранённый ключ</span><input name="clearKey" type="checkbox" class="switch"></label>
296
+ <div class="row-gap" style="margin-top:4px">
297
+ <button class="primary" type="submit" id="llm-add-btn">Сохранить подключение</button>
298
+ <button type="button" id="llm-cancel" hidden>Отмена</button>
299
+ </div>
300
+ </form>
301
+ </details>
302
+ </div>
303
+ <div id="llm-list" class="llm-prov-grid"></div>
279
304
  </div>
280
305
  </section>
281
306
  </div>
@@ -7,6 +7,7 @@
7
7
  <title>НейроАльфа — ИИ-автопилот для рынка акций</title>
8
8
  <style>
9
9
  :root {
10
+ color-scheme: dark;
10
11
  --bg: #060911;
11
12
  --bg2: #0b101d;
12
13
  --panel: rgba(17, 24, 39, 0.72);
@@ -30,7 +31,7 @@
30
31
  font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
31
32
  overflow-x: hidden;
32
33
  }
33
- a { color: inherit; text-decoration: none; }
34
+ a, a:visited { color: inherit; text-decoration: none; }
34
35
  .wrap { max-width: 1200px; margin: 0 auto; padding: 0 24px; position: relative; z-index: 2; }
35
36
 
36
37
  /* ---------- Фоновое свечение и сетка ---------- */
package/public/legal.html CHANGED
@@ -6,10 +6,11 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1">
7
7
  <title>Правовые документы · НейроАльфа</title>
8
8
  <style>
9
- :root { --bg: #0b0e14; --panel: #131822; --line: #242c3d; --text: #eef2f6; --muted: #94a3b8; --accent: #3b82f6; }
9
+ :root { color-scheme: dark; --bg: #0b0e14; --panel: #131822; --line: #242c3d; --text: #eef2f6; --muted: #94a3b8; --accent: #3b82f6; }
10
10
  * { box-sizing: border-box; }
11
11
  body { margin: 0; background: var(--bg); color: var(--text); font: 15.5px/1.65 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
12
- a { color: var(--accent); text-decoration: none; }
12
+ a, a:visited { color: var(--accent); text-decoration: none; }
13
+ a.brand, a.brand:visited { color: var(--text); }
13
14
  .wrap { max-width: 900px; margin: 0 auto; padding: 36px 24px 70px; }
14
15
  .top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
15
16
  .brand { font-weight: 700; font-size: 19px; color: var(--text); }
package/public/styles.css CHANGED
@@ -1,11 +1,15 @@
1
1
  /* Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software. */
2
2
  :root {
3
+ color-scheme: dark;
3
4
  --bg: #0d1016;
4
- --panel: #13171f;
5
- --line: #2a313e;
5
+ --panel: #151a24;
6
+ --line: #30394a;
7
+ --border: #30394a;
8
+ --bg1: #0d1016;
9
+ --bg2: #151a24;
6
10
  --text: #e6edf3;
7
- --muted: #98a2b3;
8
- --accent: #4f8cff;
11
+ --muted: #9da7b8;
12
+ --accent: #5895ff;
9
13
  --green: #3fb950;
10
14
  --red: #f85149;
11
15
  --yellow: #d29922;
@@ -13,13 +17,17 @@
13
17
  --best-bg: #13241a;
14
18
  --flash-bg: #2a1f45;
15
19
  --chat-w: 38%;
16
- --accent-bg: rgba(79, 140, 255, 0.14);
17
- --hover-bg: rgba(255, 255, 255, 0.05);
20
+ --accent-bg: rgba(88, 149, 255, 0.15);
21
+ --hover-bg: rgba(255, 255, 255, 0.06);
18
22
  }
19
23
  body.light {
24
+ color-scheme: light;
20
25
  --bg: #f6f8fa;
21
26
  --panel: #ffffff;
22
27
  --line: #d0d7de;
28
+ --border: #d0d7de;
29
+ --bg1: #f6f8fa;
30
+ --bg2: #ffffff;
23
31
  --text: #1f2328;
24
32
  --muted: #57606a;
25
33
  --accent: #2457d6;
@@ -35,6 +43,8 @@ body.light {
35
43
  * { box-sizing: border-box; }
36
44
  html, body { height: 100%; }
37
45
  body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overflow: hidden; }
46
+ a, a:visited { color: var(--accent); text-decoration: none; }
47
+ a:hover { color: var(--accent); text-decoration: underline; }
38
48
  .muted { color: var(--muted); }
39
49
  .small { font-size: 12px; }
40
50
  .grow { flex: 1; }
@@ -52,8 +62,11 @@ header { height: 48px; display: flex; justify-content: space-between; align-item
52
62
  .dot.bad { background: var(--red); }
53
63
 
54
64
  input, select, textarea { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; font: inherit; }
65
+ input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
66
+ input::placeholder, textarea::placeholder { color: var(--muted); opacity: 0.82; }
67
+ select option, select optgroup { background: var(--panel); color: var(--text); }
55
68
  textarea { resize: none; width: 100%; }
56
- button, a.hdr-btn {
69
+ button, a.hdr-btn, a.hdr-btn:visited, a.btn, a.btn:visited {
57
70
  display: inline-flex;
58
71
  align-items: center;
59
72
  justify-content: center;
@@ -71,10 +84,10 @@ button, a.hdr-btn {
71
84
  text-decoration: none;
72
85
  transition: background .15s, border-color .15s, color .15s;
73
86
  }
74
- button:hover:not(:disabled), a.hdr-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--hover-bg); }
87
+ button:hover:not(:disabled), a.hdr-btn:hover, a.btn:hover { border-color: var(--accent); color: var(--accent); background: var(--hover-bg); text-decoration: none; }
75
88
  button:disabled { opacity: .55; cursor: default; }
76
- button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
77
- button.primary:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); color: #fff; filter: brightness(1.08); }
89
+ button.primary, a.btn.primary, a.btn.primary:visited { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
90
+ button.primary:hover:not(:disabled), a.btn.primary:hover { background: var(--accent); border-color: var(--accent); color: #fff; filter: brightness(1.08); text-decoration: none; }
78
91
  button.icon { padding: 4px 9px; color: var(--text); background: var(--panel); border: 1px solid var(--line); }
79
92
  button.icon-lg { width: 40px; height: 40px; padding: 0; font-size: 15px; flex: none; }
80
93
  .hdr-btn { padding: 4px 10px; font-size: 12.5px; }
@@ -355,18 +368,60 @@ input[type=checkbox].switch::after { content: ""; position: absolute; width: 16p
355
368
  input[type=checkbox].switch:checked { background: var(--accent); border-color: var(--accent); }
356
369
  input[type=checkbox].switch:checked::after { transform: translateX(16px); background: #fff; }
357
370
  .chat-head #chat-model { max-width: 190px; min-width: 0; text-overflow: ellipsis; }
358
- #llm-box details { margin-top: 12px; }
359
- .llm-prov { border-bottom: 1px solid var(--line); padding: 8px 0; }
360
- .llm-prov .llm-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
361
- .llm-prov .llm-head button { padding: 2px 9px; font-size: 12px; }
362
- .llm-prov .llm-meta { font-size: 12px; color: var(--muted); word-break: break-all; }
363
- .llm-prov .llm-meta .nokey { color: var(--yellow); }
364
- .llm-models { margin-top: 4px; font-size: 12.5px; }
365
- .llm-models .m { display: flex; align-items: center; gap: 8px; padding: 1px 0; }
366
- .llm-models .m.on b { color: var(--accent); }
367
- .llm-models .m label { color: var(--muted); font-size: 12px; display: inline-flex; align-items: center; gap: 4px; }
368
- .llm-remote { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
369
- .llm-remote button { padding: 1px 8px; font-size: 12px; border-radius: 999px; color: var(--muted); }
371
+ #llm-box details { margin-top: 10px; }
372
+ .llm-active-hero { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; padding: 14px 16px; border: 1px solid rgba(59, 130, 246, 0.35); border-radius: 12px; background: linear-gradient(135deg, rgba(59, 130, 246, 0.10), rgba(16, 185, 129, 0.05)); margin-bottom: 14px; }
373
+ .llm-active-hero.off { border-color: rgba(245, 158, 11, 0.45); background: rgba(245, 158, 11, 0.07); }
374
+ .llm-active-main { display: grid; gap: 4px; min-width: 220px; flex: 1; }
375
+ .llm-active-kicker { font-size: 11px; text-transform: uppercase; letter-spacing: 0.45px; color: var(--muted); font-weight: 600; display: flex; align-items: center; gap: 6px; }
376
+ .llm-active-title { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
377
+ .llm-active-sub { font-size: 12.5px; color: var(--muted); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
378
+ .llm-test-res { margin-top: 6px; font-size: 12px; padding: 6px 10px; border-radius: 8px; background: var(--bg); border: 1px solid var(--line); }
379
+ .llm-test-res.ok { border-color: rgba(16, 185, 129, 0.45); color: var(--green); }
380
+ .llm-test-res.err { border-color: rgba(239, 68, 68, 0.45); color: var(--red); }
381
+
382
+ .llm-form-card { border: 1px solid var(--line); border-radius: 12px; background: var(--bg); padding: 12px 16px; margin-bottom: 14px; }
383
+ .llm-form-card[open] { border-color: var(--accent); }
384
+ .llm-form-card > summary { cursor: pointer; font-weight: 600; font-size: 14px; color: var(--accent); user-select: none; }
385
+ .llm-presets-wrap { margin-top: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
386
+ .llm-presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); gap: 7px; }
387
+ .llm-preset-btn { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); font-size: 12.5px; font-weight: 600; cursor: pointer; transition: border-color .15s, background .15s; }
388
+ .llm-preset-btn span { font-size: 11px; font-weight: 400; color: var(--muted); }
389
+ .llm-preset-btn:hover, .llm-preset-btn.active { border-color: var(--accent); background: rgba(59, 130, 246, 0.09); }
390
+ .llm-preset-hint { margin-top: 8px; padding: 8px 11px; border-radius: 8px; background: rgba(59, 130, 246, 0.08); border: 1px solid rgba(59, 130, 246, 0.28); font-size: 12px; line-height: 1.5; color: var(--text); }
391
+
392
+ .llm-prov-grid { display: grid; gap: 12px; }
393
+ .llm-prov-card { border: 1px solid var(--line); border-radius: 12px; background: var(--bg); padding: 14px 16px; transition: border-color .15s; }
394
+ .llm-prov-card.is-active { border-color: rgba(59, 130, 246, 0.55); box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.15); }
395
+ .llm-prov-card .llm-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
396
+ .llm-prov-card .llm-title { font-size: 14.5px; font-weight: 700; display: flex; align-items: center; gap: 7px; }
397
+ .llm-prov-card .llm-head button { padding: 4px 10px; font-size: 12px; }
398
+ .llm-prov-card .llm-meta { margin-top: 4px; font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
399
+ .llm-prov-card .llm-meta .nokey { color: var(--yellow); font-weight: 600; }
400
+
401
+ .llm-models { margin-top: 10px; display: grid; gap: 6px; }
402
+ .llm-model-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 11px; border-radius: 9px; border: 1px solid var(--line); background: var(--panel); transition: border-color .15s, background .15s; }
403
+ .llm-model-row:hover { border-color: rgba(59, 130, 246, 0.4); }
404
+ .llm-model-row.on { border-color: var(--accent); background: rgba(59, 130, 246, 0.08); }
405
+ .llm-model-pick { display: flex; align-items: center; gap: 9px; cursor: pointer; flex: 1; min-width: 180px; background: transparent; border: 0; padding: 0; text-align: left; color: var(--text); font-size: 13px; }
406
+ .llm-model-pick:hover { background: transparent; border-color: transparent; }
407
+ .llm-radio { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--muted); display: inline-flex; align-items: center; justify-content: center; flex: none; }
408
+ .llm-model-row.on .llm-radio { border-color: var(--accent); background: var(--accent); }
409
+ .llm-model-row.on .llm-radio::after { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #fff; }
410
+ .llm-model-info { display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; min-width: 0; }
411
+ .llm-model-info b { font-weight: 600; font-size: 13.5px; }
412
+ .llm-model-row.on .llm-model-info b { color: var(--accent); }
413
+ .llm-model-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
414
+ .llm-tools-lbl { color: var(--muted); font-size: 11.5px; display: inline-flex; align-items: center; gap: 5px; cursor: pointer; user-select: none; padding: 2px 7px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); }
415
+ .llm-tools-lbl.on { color: var(--text); border-color: rgba(16, 185, 129, 0.4); }
416
+
417
+ .llm-card-foot { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--line); display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
418
+ .llm-quick-add { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 220px; max-width: 420px; }
419
+ .llm-quick-add input { flex: 1; min-width: 0; padding: 4px 9px; font-size: 12px; }
420
+ .llm-quick-add button { padding: 4px 10px; font-size: 12px; white-space: nowrap; }
421
+ .llm-remote-box { margin-top: 8px; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); }
422
+ .llm-remote { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; max-height: 170px; overflow-y: auto; }
423
+ .llm-remote button { padding: 3px 9px; font-size: 12px; border-radius: 999px; color: var(--text); background: var(--bg); }
424
+ .llm-remote button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
370
425
  .llm-remote button:disabled { opacity: .5; }
371
426
  .row-gap { display: flex; gap: 8px; }
372
427
  form#llm-add, form#broker-form { display: grid; gap: 10px; min-width: 0; }
@@ -8,6 +8,7 @@ import { Readable } from 'node:stream';
8
8
  import { ROOT, llmConfig, DEFAULT_CLOUD_URL } from '../config.js';
9
9
  import { kvGet, kvSet } from '../db.js';
10
10
  import { encryptSecret, decryptSecret } from '../auth/index.js';
11
+ import { TRUSTED_CA } from '../tls-ca.js';
11
12
  import { chatStream } from './client.js';
12
13
 
13
14
  const PROTOCOLS = ['openai', 'anthropic', 'gigachat', 'ollama'];
@@ -72,13 +73,13 @@ function modelsUrls(p) {
72
73
  }
73
74
 
74
75
  // ---------- сеть ----------
75
- // Сертификат НУЦ Минцифры для GigaChat: путь задаётся у подключения или в GIGACHAT_CA_FILE
76
+ // Сертификат НУЦ Минцифры для GigaChat: по умолчанию берётся встроенный TRUSTED_CA (или переопределяется в p.caFile)
76
77
  const caCache = new Map();
77
78
  function caList(p) {
78
79
  const file = String(p.caFile || process.env.GIGACHAT_CA_FILE || '').trim();
79
- if (!file) return null;
80
+ if (!file) return TRUSTED_CA;
80
81
  if (!caCache.has(file)) {
81
- try { caCache.set(file, [...tls.rootCertificates, fs.readFileSync(path.resolve(ROOT, file), 'utf8')]); } catch { caCache.set(file, null); }
82
+ try { caCache.set(file, [...tls.rootCertificates, fs.readFileSync(path.resolve(ROOT, file), 'utf8')]); } catch { caCache.set(file, TRUSTED_CA); }
82
83
  }
83
84
  return caCache.get(file);
84
85
  }