feat: Поиск - with it on the assistant searches DuckDuckGo itself and answers from what it found; without it, it cannot. «Думал N сек.»

This commit is contained in:
redtea committed 2026-10-16 10:32:33 +03:00
1 parent fa62f2c5b6
commit 9bf30b281e
8 files changed
+921 -7

No files matched your search

+20 -6
View File
@@ -28,6 +28,8 @@
'<div class="ai-pills">' +
'<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M9 13v2M15 13v2"/></svg>Агент</button>' +
'<button type="button" class="ai-pill" data-mode="search" aria-pressed="false" title="Поиск в интернете (DuckDuckGo): ассистент ищет сам, когда нужно">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>Поиск</button>' +
'<button type="button" class="ai-pill" data-mode="think" aria-pressed="false" title="Сначала обдумывает, потом отвечает: дольше, но точнее">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0 0 12 2z"/></svg>Думать</button>' +
'</div>' +
@@ -314,6 +316,14 @@
const b64 = text => { const u = new TextEncoder().encode(text); let s = ''; for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s); };
// perform does one action with the user's rights and tells what came of it.
async function perform(a, signal) {
// A web search: through the server, only with «Поиск» on.
if (a.kind === 'search') {
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
const r = await fetch('/api/panel/ai/search', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
body: JSON.stringify({q: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'}));
if (!r.ok) return 'Ошибка поиска «' + a.body + '»: ' + r.error;
return 'Результаты поиска «' + a.body + '»:\n' + r.data.map((x, i) => (i + 1) + '. ' + x.title + ' - ' + x.url + (x.snippet ? '\n ' + x.snippet : '')).join('\n');
}
await loadMachines();
const m = machineOf(a.machine);
if (!m) return 'Ошибка: нет машины «' + (a.machine || '?') + '». Машины: ' + machines.map(x => x.name).join(', ');
@@ -355,11 +365,12 @@
// ---------- messages ----------
// stripThink: the answer without its thinking - what is acted on and kept.
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись'};
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск'};
const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>'),
ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>')};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать'};
ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>')};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти'};
// render: text with ``` blocks; action blocks get their button.
// render draws an answer: its thinking, folded, above; the text with
// Markdown; ``` blocks apart, action blocks with their button. The
@@ -384,7 +395,7 @@
think.classList.toggle('going', going);
// How long: timed here as it streams, or kept with the answer.
const ms = el._thinkMs || (el._t1 - el._t0);
think.querySelector('.ai-think-h .lbl').textContent = going ? 'Думаю…' : 'Думал ' + Math.max(1, Math.round(ms / 1000)) + ' с';
think.querySelector('.ai-think-h .lbl').textContent = going ? 'Думаю…' : 'Думал ' + Math.max(1, Math.round(ms / 1000)) + ' сек.';
think.querySelector('.ai-think-in').innerHTML = WSUtil.md(th[1].trim());
text = text.slice(th[0].length);
}
@@ -459,7 +470,7 @@
await loadMachines();
const r = await fetch('/api/panel/ai/chat', {
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), chat: chatId})
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId})
});
// A new chat gets its id (and its name) from the answer.
const cid = Number(r.headers.get('X-Chat-Id')) || 0;
@@ -489,6 +500,7 @@
const pill = k => q('.ai-pill[data-mode="' + k + '"]');
const agentOn = () => pill('agent').getAttribute('aria-pressed') === 'true';
const thinkOn = () => pill('think').getAttribute('aria-pressed') === 'true';
const searchOn = () => pill('search').getAttribute('aria-pressed') === 'true';
// The modes are kept in this browser.
box.querySelectorAll('.ai-pill').forEach(b => {
try { b.setAttribute('aria-pressed', String(localStorage.getItem('lpra.ai.' + b.dataset.mode) === '1')); } catch (e) { /* off */ }
@@ -503,9 +515,11 @@
// and its result sent back - until an answer asks for nothing.
async function talkTurn(text) {
let turn = await ask(text);
for (let step = 0; agentOn() && ac && !ac.signal.aborted && step < 15; step++) {
for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) {
const a = WSUtil.parseActions(turn.answer)[0];
if (!a) break;
// Without «Агент» only searches go on by themselves, with «Поиск» on.
if (!agentOn() && !(a.kind === 'search' && searchOn())) break;
const b = turn.el.querySelector('.ai-act');
if (needsYes(a) && !await UI.confirm(describe(a), {ok: a.kind === 'run' ? 'Выполнить' : 'Записать'})) {
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
+2 -1
View File
@@ -48,10 +48,11 @@
// closed ``` blocks headed run [machine], read|ls [machine:]path or
// write [machine:]path (its body the file's text).
function parseActions(text) {
const out = [], re = /```(run|read|ls|write)([^\n]*)\n([\s\S]*?)```/g;
const out = [], re = /```(run|read|ls|write|search)([^\n]*)\n([\s\S]*?)```/g;
for (let m; (m = re.exec(text));) {
const kind = m[1], head = m[2].trim(), body = m[3].replace(/\n$/, '');
if (kind === 'run') { out.push({kind, machine: head, path: '', body}); continue; }
if (kind === 'search') { out.push({kind, machine: '', path: '', body: (head + '\n' + body).trim()}); continue; }
const c = head.indexOf(':');
const machine = c > 0 && !head.startsWith('/') ? head.slice(0, c) : '';
out.push({kind, machine, path: machine ? head.slice(c + 1) : head, body: kind === 'write' ? body : ''});
@@ -75,3 +75,8 @@ test('md: the common marks, everything else escaped', () => {
// Inside code nothing is marked up.
assert.strictEqual(U.md('`**a**`'), '<p><code>**a**</code></p>');
});
test('parseActions: search blocks', () => {
assert.deepStrictEqual(U.parseActions('Ищу:\n```search\nnginx 1.27 changelog\n```'),
[{kind: 'search', machine: '', path: '', body: 'nginx 1.27 changelog'}]);
});