neuroalpha 0.5.14 → 0.5.16
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 +1 -1
- package/public/app.js +393 -48
- package/public/index.html +53 -28
- package/public/styles.css +54 -12
- package/server/instance/central.js +2 -1
- package/server/llm/providers.js +4 -3
- package/server/notify/index.js +7 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.16",
|
|
4
4
|
"description": "НейроАльфа — ИИ-автопилот для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор и боты Telegram / MAX",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -1612,21 +1612,101 @@ feed.addEventListener('click', async (e) => {
|
|
|
1612
1612
|
// ======================================================================
|
|
1613
1613
|
// МОДЕЛИ ИИ
|
|
1614
1614
|
// ======================================================================
|
|
1615
|
-
const PROTO_LABEL = {
|
|
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 =
|
|
1665
|
-
|
|
1666
|
-
$('#llm-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
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
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
1747
|
-
|
|
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
|
-
|
|
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 ===
|
|
1761
|
-
|
|
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 '
|
|
1765
|
-
|
|
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}»?`)) {
|
|
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)' : '
|
|
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 =
|
|
1812
|
-
$('#llm-add-btn').textContent = 'Сохранить';
|
|
2128
|
+
$('#llm-add-sum').textContent = `⚙ Настройка подключения «${p.name}»`;
|
|
2129
|
+
$('#llm-add-btn').textContent = 'Сохранить изменения';
|
|
1813
2130
|
$('#llm-cancel').hidden = false;
|
|
1814
|
-
|
|
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
|
-
|
|
1822
|
-
$('#llm-
|
|
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',
|
|
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,
|
|
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
|
-
|
|
1845
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
252
|
-
<div id="llm-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
<
|
|
261
|
-
<
|
|
262
|
-
<
|
|
263
|
-
<
|
|
264
|
-
</
|
|
265
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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>
|
package/public/styles.css
CHANGED
|
@@ -355,18 +355,60 @@ input[type=checkbox].switch::after { content: ""; position: absolute; width: 16p
|
|
|
355
355
|
input[type=checkbox].switch:checked { background: var(--accent); border-color: var(--accent); }
|
|
356
356
|
input[type=checkbox].switch:checked::after { transform: translateX(16px); background: #fff; }
|
|
357
357
|
.chat-head #chat-model { max-width: 190px; min-width: 0; text-overflow: ellipsis; }
|
|
358
|
-
#llm-box details { margin-top:
|
|
359
|
-
.llm-
|
|
360
|
-
.llm-
|
|
361
|
-
.llm-
|
|
362
|
-
.llm-
|
|
363
|
-
.llm-
|
|
364
|
-
.llm-
|
|
365
|
-
.llm-
|
|
366
|
-
.llm-
|
|
367
|
-
.llm-
|
|
368
|
-
|
|
369
|
-
.llm-
|
|
358
|
+
#llm-box details { margin-top: 10px; }
|
|
359
|
+
.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; }
|
|
360
|
+
.llm-active-hero.off { border-color: rgba(245, 158, 11, 0.45); background: rgba(245, 158, 11, 0.07); }
|
|
361
|
+
.llm-active-main { display: grid; gap: 4px; min-width: 220px; flex: 1; }
|
|
362
|
+
.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; }
|
|
363
|
+
.llm-active-title { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
364
|
+
.llm-active-sub { font-size: 12.5px; color: var(--muted); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
365
|
+
.llm-test-res { margin-top: 6px; font-size: 12px; padding: 6px 10px; border-radius: 8px; background: var(--bg); border: 1px solid var(--line); }
|
|
366
|
+
.llm-test-res.ok { border-color: rgba(16, 185, 129, 0.45); color: var(--green); }
|
|
367
|
+
.llm-test-res.err { border-color: rgba(239, 68, 68, 0.45); color: var(--red); }
|
|
368
|
+
|
|
369
|
+
.llm-form-card { border: 1px solid var(--line); border-radius: 12px; background: var(--bg); padding: 12px 16px; margin-bottom: 14px; }
|
|
370
|
+
.llm-form-card[open] { border-color: var(--accent); }
|
|
371
|
+
.llm-form-card > summary { cursor: pointer; font-weight: 600; font-size: 14px; color: var(--accent); user-select: none; }
|
|
372
|
+
.llm-presets-wrap { margin-top: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
|
|
373
|
+
.llm-presets { display: grid; grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); gap: 7px; }
|
|
374
|
+
.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; }
|
|
375
|
+
.llm-preset-btn span { font-size: 11px; font-weight: 400; color: var(--muted); }
|
|
376
|
+
.llm-preset-btn:hover, .llm-preset-btn.active { border-color: var(--accent); background: rgba(59, 130, 246, 0.09); }
|
|
377
|
+
.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); }
|
|
378
|
+
|
|
379
|
+
.llm-prov-grid { display: grid; gap: 12px; }
|
|
380
|
+
.llm-prov-card { border: 1px solid var(--line); border-radius: 12px; background: var(--bg); padding: 14px 16px; transition: border-color .15s; }
|
|
381
|
+
.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); }
|
|
382
|
+
.llm-prov-card .llm-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
383
|
+
.llm-prov-card .llm-title { font-size: 14.5px; font-weight: 700; display: flex; align-items: center; gap: 7px; }
|
|
384
|
+
.llm-prov-card .llm-head button { padding: 4px 10px; font-size: 12px; }
|
|
385
|
+
.llm-prov-card .llm-meta { margin-top: 4px; font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
386
|
+
.llm-prov-card .llm-meta .nokey { color: var(--yellow); font-weight: 600; }
|
|
387
|
+
|
|
388
|
+
.llm-models { margin-top: 10px; display: grid; gap: 6px; }
|
|
389
|
+
.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; }
|
|
390
|
+
.llm-model-row:hover { border-color: rgba(59, 130, 246, 0.4); }
|
|
391
|
+
.llm-model-row.on { border-color: var(--accent); background: rgba(59, 130, 246, 0.08); }
|
|
392
|
+
.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; }
|
|
393
|
+
.llm-model-pick:hover { background: transparent; border-color: transparent; }
|
|
394
|
+
.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; }
|
|
395
|
+
.llm-model-row.on .llm-radio { border-color: var(--accent); background: var(--accent); }
|
|
396
|
+
.llm-model-row.on .llm-radio::after { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #fff; }
|
|
397
|
+
.llm-model-info { display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; min-width: 0; }
|
|
398
|
+
.llm-model-info b { font-weight: 600; font-size: 13.5px; }
|
|
399
|
+
.llm-model-row.on .llm-model-info b { color: var(--accent); }
|
|
400
|
+
.llm-model-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
401
|
+
.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); }
|
|
402
|
+
.llm-tools-lbl.on { color: var(--text); border-color: rgba(16, 185, 129, 0.4); }
|
|
403
|
+
|
|
404
|
+
.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; }
|
|
405
|
+
.llm-quick-add { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 220px; max-width: 420px; }
|
|
406
|
+
.llm-quick-add input { flex: 1; min-width: 0; padding: 4px 9px; font-size: 12px; }
|
|
407
|
+
.llm-quick-add button { padding: 4px 10px; font-size: 12px; white-space: nowrap; }
|
|
408
|
+
.llm-remote-box { margin-top: 8px; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); }
|
|
409
|
+
.llm-remote { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; max-height: 170px; overflow-y: auto; }
|
|
410
|
+
.llm-remote button { padding: 3px 9px; font-size: 12px; border-radius: 999px; color: var(--text); background: var(--bg); }
|
|
411
|
+
.llm-remote button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
370
412
|
.llm-remote button:disabled { opacity: .5; }
|
|
371
413
|
.row-gap { display: flex; gap: 8px; }
|
|
372
414
|
form#llm-add, form#broker-form { display: grid; gap: 10px; min-width: 0; }
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import crypto from 'node:crypto';
|
|
8
8
|
import { db } from '../db.js';
|
|
9
9
|
import { getUserById, effectivePlan, ensureApiKey } from '../auth/index.js';
|
|
10
|
-
import { notifyUser, registerInstanceActionHandler } from '../notify/index.js';
|
|
10
|
+
import { notifyUser, registerInstanceActionHandler, registerInstanceChatRelay } from '../notify/index.js';
|
|
11
11
|
import { signToken, centralPublicKeyPem } from './signing.js';
|
|
12
12
|
import { PAAS_DRIVER, provisioningEnabled, provisionApply, provisionDelete } from './provision.js';
|
|
13
13
|
|
|
@@ -419,6 +419,7 @@ export function queueInstanceChatCommand(userId, text, channel = 'tg') {
|
|
|
419
419
|
const online = Boolean(inst.last_seen && Date.now() - inst.last_seen < 3 * 60000);
|
|
420
420
|
return { instanceId: inst.id, online };
|
|
421
421
|
}
|
|
422
|
+
registerInstanceChatRelay(queueInstanceChatCommand);
|
|
422
423
|
|
|
423
424
|
// Команды для машины: отдаются в каждой лицензии, пока машина не подтвердит выполнение (ack)
|
|
424
425
|
function pendingCommands(instId, acked = []) {
|
package/server/llm/providers.js
CHANGED
|
@@ -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:
|
|
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
|
|
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,
|
|
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
|
}
|
package/server/notify/index.js
CHANGED
|
@@ -5,9 +5,8 @@ import { TRUSTED_CA } from '../tls-ca.js';
|
|
|
5
5
|
import { IS_INSTANCE } from '../role.js';
|
|
6
6
|
import { getIdentity } from '../instance/identity.js';
|
|
7
7
|
import { relayNotifyToCentral } from '../instance/agent.js';
|
|
8
|
-
import { db, kvGet, kvSet } from '../db.js';
|
|
9
|
-
import { encryptSecret, decryptSecret, getUserById, runWithUser, generateApiKey, hashPassword, ensureApiKey, effectivePlan
|
|
10
|
-
import { queueInstanceChatCommand } from '../instance/central.js';
|
|
8
|
+
import { db, kvGet, kvSet, countUserChatToday } from '../db.js';
|
|
9
|
+
import { encryptSecret, decryptSecret, getUserById, runWithUser, generateApiKey, hashPassword, ensureApiKey, effectivePlan } from '../auth/index.js';
|
|
11
10
|
import { round } from '../util.js';
|
|
12
11
|
|
|
13
12
|
let actionHandlers = { execute: null, reject: null };
|
|
@@ -16,6 +15,9 @@ export function registerBrokerHandlers(h) { actionHandlers = h; }
|
|
|
16
15
|
let chatHandlers = { ask: null, resolve: null };
|
|
17
16
|
export function registerChatHandlers(h) { chatHandlers = h; }
|
|
18
17
|
|
|
18
|
+
let instanceChatRelay = null;
|
|
19
|
+
export function registerInstanceChatRelay(fn) { instanceChatRelay = fn; }
|
|
20
|
+
|
|
19
21
|
// Надёжный HTTP-клиент для platform-api2.max.ru (поддерживает сертификат НУЦ Минцифры РФ)
|
|
20
22
|
const maxCa = TRUSTED_CA;
|
|
21
23
|
|
|
@@ -270,8 +272,8 @@ async function handleMessengerChat(text, channel, chatId, sendFn) {
|
|
|
270
272
|
const hasLocalAccounts = Boolean(
|
|
271
273
|
db.prepare('SELECT 1 FROM broker_accounts WHERE user_id = ? LIMIT 1').get(u.id),
|
|
272
274
|
);
|
|
273
|
-
if (!hasLocalAccounts && u.role !== 'admin') {
|
|
274
|
-
const relayed =
|
|
275
|
+
if (!hasLocalAccounts && u.role !== 'admin' && instanceChatRelay) {
|
|
276
|
+
const relayed = instanceChatRelay(u.id, text, channel);
|
|
275
277
|
if (relayed) {
|
|
276
278
|
await sendFn(relayed.online
|
|
277
279
|
? '⏳ Запрос передан Стратегу на вашу машину — ответ придёт сюда через несколько секунд…'
|