feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
This commit is contained in:
+307
-74
@@ -74,6 +74,36 @@ class BooksLM {
|
||||
this._sessions = [];
|
||||
this._currentSessionId = null;
|
||||
this._pendingNewSession = false;
|
||||
// Agent mode: routes chat through /api/ai/bookslm/agent so the model can
|
||||
// call read/search tools and propose mutations (confirmation cards).
|
||||
this._agentMode = this._readAgentMode();
|
||||
}
|
||||
|
||||
_readAgentMode() {
|
||||
try {
|
||||
return localStorage.getItem('obsigate-bookslm-agent') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_toggleAgentMode() {
|
||||
this._agentMode = !this._agentMode;
|
||||
try {
|
||||
localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false');
|
||||
} catch { /* private mode */ }
|
||||
this._updateAgentToggle();
|
||||
}
|
||||
|
||||
_updateAgentToggle() {
|
||||
if (!this._panel) return;
|
||||
const btn = this._panel.querySelector('.bookslm-btn-agent');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('active', this._agentMode);
|
||||
const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off');
|
||||
btn.title = label;
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.setAttribute('aria-pressed', this._agentMode ? 'true' : 'false');
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
@@ -376,6 +406,7 @@ class BooksLM {
|
||||
<span class="bookslm-subtitle"></span>
|
||||
</div>
|
||||
<div class="bookslm-header-actions">
|
||||
<button class="bookslm-btn-agent" title="${t('ai.agent_mode_off')}" aria-label="${t('ai.agent_mode_off')}" aria-pressed="false"><i data-lucide="bot" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button>
|
||||
@@ -409,6 +440,7 @@ class BooksLM {
|
||||
|
||||
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
|
||||
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
|
||||
panel.querySelector('.bookslm-btn-agent').addEventListener('click', () => this._toggleAgentMode());
|
||||
panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggleHistoryMenu();
|
||||
@@ -567,12 +599,18 @@ class BooksLM {
|
||||
if (msg.role === 'assistant') {
|
||||
const { text, actions } = this._extractActions(msg.content || '');
|
||||
bubble.innerHTML = this._renderMarkdown(text);
|
||||
if (msg.toolCalls && msg.toolCalls.length) {
|
||||
bubble.insertBefore(this._renderToolActivity(msg.toolCalls), bubble.firstChild);
|
||||
}
|
||||
if (actions.length) {
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'bookslm-actions';
|
||||
actions.forEach((action) => actionsDiv.appendChild(this._renderActionCard(action)));
|
||||
bubble.appendChild(actionsDiv);
|
||||
}
|
||||
if (msg.confirmation) {
|
||||
bubble.appendChild(this._renderConfirmationCard(msg));
|
||||
}
|
||||
if (msg.sources && msg.sources.length) {
|
||||
const sourcesDiv = document.createElement('div');
|
||||
sourcesDiv.className = 'bookslm-sources';
|
||||
@@ -673,6 +711,172 @@ class BooksLM {
|
||||
} catch { /* tree refresh is best-effort */ }
|
||||
}
|
||||
|
||||
// ── Tool activity & confirmations (agent mode) ──────────────────────
|
||||
|
||||
_renderToolActivity(toolCalls) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'bookslm-tool-trace';
|
||||
for (const call of toolCalls) {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : '');
|
||||
line.textContent = `${call.ok === false ? '⚠' : '🔧'} ${t('ai.tool_call', { name: call.name })}`;
|
||||
if (call.ok === false) line.title = t('ai.tool_call_failed', { name: call.name });
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
_renderConfirmationCard(msg) {
|
||||
const conf = msg.confirmation;
|
||||
const pending = conf.pending || {};
|
||||
const error = pending.error || pending;
|
||||
const tool = error.tool || 'action';
|
||||
const args = error.arguments || {};
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bookslm-action bookslm-confirm';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'bookslm-action-meta';
|
||||
meta.innerHTML = '<span class="bookslm-action-icon">🔒</span>';
|
||||
const textEl = document.createElement('span');
|
||||
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
|
||||
meta.appendChild(textEl);
|
||||
card.appendChild(meta);
|
||||
|
||||
const diffHost = document.createElement('div');
|
||||
diffHost.className = 'bookslm-confirm-diff';
|
||||
if (conf._diffHtml) {
|
||||
diffHost.innerHTML = conf._diffHtml;
|
||||
} else if (!conf._diffLoading) {
|
||||
conf._diffLoading = true;
|
||||
this._fillConfirmationDiff(args, conf).then(() => this._renderMessages());
|
||||
}
|
||||
card.appendChild(diffHost);
|
||||
|
||||
const apply = document.createElement('button');
|
||||
apply.className = 'bookslm-action-apply';
|
||||
apply.textContent = t('ai.action_apply');
|
||||
apply.addEventListener('click', async () => {
|
||||
apply.disabled = true;
|
||||
apply.textContent = t('ai.action_applying');
|
||||
try {
|
||||
await this._applyConfirmation(msg);
|
||||
apply.textContent = t('ai.action_applied');
|
||||
} catch (e) {
|
||||
apply.disabled = false;
|
||||
apply.textContent = t('ai.action_apply');
|
||||
showToast(t('ai.action_failed', { error: e.message }), 'error');
|
||||
}
|
||||
});
|
||||
card.appendChild(apply);
|
||||
return card;
|
||||
}
|
||||
|
||||
async _fillConfirmationDiff(args, conf) {
|
||||
const proposed = args.content;
|
||||
if (typeof proposed !== 'string' || !args.vault || !args.path) return;
|
||||
let current = null;
|
||||
try {
|
||||
current = await this._fetchCurrentContent(args.vault, args.path);
|
||||
} catch {
|
||||
current = null;
|
||||
}
|
||||
const title = `<div class="bookslm-diff-title">${this._escapeHtml(t('ai.diff_preview'))}</div>`;
|
||||
if (current == null) {
|
||||
// New file (or unreadable): show everything as an addition.
|
||||
conf._diffHtml = title + `<pre class="bookslm-diff">${this._renderDiffLines([{ t: '+', s: proposed }])}</pre>`;
|
||||
return;
|
||||
}
|
||||
const lines = this._lineDiff(current, proposed);
|
||||
conf._diffHtml = title + `<pre class="bookslm-diff">${this._renderDiffLines(lines)}</pre>`;
|
||||
}
|
||||
|
||||
async _fetchCurrentContent(vault, path) {
|
||||
const data = await api(`/api/file/${encodeURIComponent(vault)}/raw?path=${encodeURIComponent(path)}`);
|
||||
return data && typeof data.raw === 'string' ? data.raw : '';
|
||||
}
|
||||
|
||||
/** Minimal line diff (LCS) between the current and the proposed content. */
|
||||
_lineDiff(oldText, newText) {
|
||||
const a = String(oldText || '').split('\n');
|
||||
const b = String(newText || '').split('\n');
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) { out.push({ t: ' ', s: a[i] }); i++; j++; }
|
||||
else if (dp[i + 1][j] >= dp[i][j + 1]) { out.push({ t: '-', s: a[i] }); i++; }
|
||||
else { out.push({ t: '+', s: b[j] }); j++; }
|
||||
}
|
||||
while (i < n) out.push({ t: '-', s: a[i++] });
|
||||
while (j < m) out.push({ t: '+', s: b[j++] });
|
||||
return out;
|
||||
}
|
||||
|
||||
_renderDiffLines(lines) {
|
||||
const cap = 400;
|
||||
const shown = lines.length > cap ? lines.slice(0, cap) : lines;
|
||||
let html = shown.map((l) => {
|
||||
const cls = l.t === '+' ? 'bookslm-diff-add' : (l.t === '-' ? 'bookslm-diff-del' : 'bookslm-diff-ctx');
|
||||
return `<span class="${cls}">${this._escapeHtml(l.t + ' ' + l.s)}</span>`;
|
||||
}).join('\n');
|
||||
if (lines.length > cap) {
|
||||
html += `\n<span class="bookslm-diff-ctx">… ${lines.length - cap}</span>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
async _applyConfirmation(msg) {
|
||||
const conf = msg.confirmation;
|
||||
if (!conf || conf._applying) return;
|
||||
conf._applying = true;
|
||||
|
||||
const payload = {
|
||||
...(msg.payload || {}),
|
||||
confirm: conf.pending,
|
||||
confirm_messages: conf.messages,
|
||||
};
|
||||
|
||||
this._isLoading = true;
|
||||
this._abortCtrl = new AbortController();
|
||||
const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send');
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
|
||||
const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: null };
|
||||
try {
|
||||
let resp = await this._postChat(payload);
|
||||
if (resp.status === 401 && AuthManager._authEnabled) {
|
||||
await AuthManager.refreshAccessToken();
|
||||
resp = await this._postChat(payload);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let detail = `HTTP ${resp.status}`;
|
||||
try { detail = (await resp.json()).detail || detail; } catch { /* */ }
|
||||
throw new Error(detail);
|
||||
}
|
||||
// The confirmation is resolved: drop the card and show the continuation.
|
||||
msg.confirmation = null;
|
||||
this._messages.push(continuation);
|
||||
await this._streamResponse(resp, continuation, payload);
|
||||
} finally {
|
||||
conf._applying = false;
|
||||
this._isLoading = false;
|
||||
this._abortCtrl = null;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
this._renderMessages();
|
||||
this._saveHistory();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Messaging ───────────────────────────────────────────────────────
|
||||
|
||||
async _sendMessage() {
|
||||
@@ -694,7 +898,26 @@ class BooksLM {
|
||||
this._messages.push({ role: 'user', content: text });
|
||||
this._renderMessages();
|
||||
|
||||
const assistantMsg = { role: 'assistant', content: '', sources: [] };
|
||||
let provider = null;
|
||||
let model = null;
|
||||
try {
|
||||
const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}');
|
||||
provider = picker.provider || null;
|
||||
model = picker.model || null;
|
||||
} catch { /* */ }
|
||||
|
||||
const payload = {
|
||||
mode: this._mode,
|
||||
vault: this._vault,
|
||||
directory: this._directory,
|
||||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
|
||||
message: text,
|
||||
conversation_history: history,
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
|
||||
const assistantMsg = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload };
|
||||
this._messages.push(assistantMsg);
|
||||
this._renderMessages();
|
||||
this._isLoading = true;
|
||||
@@ -705,39 +928,10 @@ class BooksLM {
|
||||
this._abortCtrl = new AbortController();
|
||||
|
||||
try {
|
||||
let provider = null;
|
||||
let model = null;
|
||||
try {
|
||||
const picker = JSON.parse(localStorage.getItem('obsigate_ai_picker') || '{}');
|
||||
provider = picker.provider || null;
|
||||
model = picker.model || null;
|
||||
} catch { /* */ }
|
||||
|
||||
const payload = {
|
||||
mode: this._mode,
|
||||
vault: this._vault,
|
||||
directory: this._directory,
|
||||
context_files: this._mode === MODE.DOCUMENTS ? this._documents.map((d) => d.path) : [],
|
||||
message: text,
|
||||
conversation_history: history,
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
|
||||
const doFetch = () => {
|
||||
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
|
||||
return fetch('/api/ai/bookslm/chat', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
signal: this._abortCtrl.signal,
|
||||
});
|
||||
};
|
||||
let resp = await doFetch();
|
||||
let resp = await this._postChat(payload);
|
||||
if (resp.status === 401 && AuthManager._authEnabled) {
|
||||
await AuthManager.refreshAccessToken();
|
||||
resp = await doFetch();
|
||||
resp = await this._postChat(payload);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let detail = `HTTP ${resp.status}`;
|
||||
@@ -745,46 +939,7 @@ class BooksLM {
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentEvent = 'message';
|
||||
|
||||
const handleLine = (line) => {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
return;
|
||||
}
|
||||
if (!line.startsWith('data:')) return;
|
||||
const raw = line.slice(5).trim();
|
||||
if (!raw || raw === '[DONE]') return;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
assistantMsg.content += raw;
|
||||
return;
|
||||
}
|
||||
if (currentEvent === 'error' || data.error) {
|
||||
const msg = data.error || t('bookslm.error');
|
||||
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
|
||||
return;
|
||||
}
|
||||
const chunk = data.token || data.content || '';
|
||||
if (chunk) assistantMsg.content += chunk;
|
||||
if (data.sources) assistantMsg.sources = data.sources;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) handleLine(line);
|
||||
this._renderMessages();
|
||||
}
|
||||
if (buffer) handleLine(buffer);
|
||||
await this._streamResponse(resp, assistantMsg, payload);
|
||||
|
||||
if (!assistantMsg.sources.length && this._contextFiles.length) {
|
||||
const contentLower = assistantMsg.content.toLowerCase();
|
||||
@@ -809,6 +964,78 @@ class BooksLM {
|
||||
this._saveHistory();
|
||||
}
|
||||
|
||||
/** POST to /chat or /agent depending on the agent-mode toggle. */
|
||||
_postChat(payload) {
|
||||
const endpoint = this._agentMode ? '/api/ai/bookslm/agent' : '/api/ai/bookslm/chat';
|
||||
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
signal: this._abortCtrl ? this._abortCtrl.signal : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume an SSE response, feeding tokens/tool/confirmation events into
|
||||
* `assistantMsg`. Shared by the initial send and the confirmation resume.
|
||||
*/
|
||||
async _streamResponse(resp, assistantMsg, payload) {
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentEvent = 'message';
|
||||
|
||||
const handleLine = (line) => {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
return;
|
||||
}
|
||||
if (!line.startsWith('data:')) return;
|
||||
const raw = line.slice(5).trim();
|
||||
if (!raw || raw === '[DONE]') return;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
assistantMsg.content += raw;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentEvent === 'tool') {
|
||||
assistantMsg.toolCalls.push({ name: data.name, ok: data.ok !== false, arguments: data.arguments || {} });
|
||||
return;
|
||||
}
|
||||
if (currentEvent === 'confirmation') {
|
||||
assistantMsg.confirmation = {
|
||||
pending: data.pending || data,
|
||||
messages: data.messages || [],
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (currentEvent === 'error' || data.error) {
|
||||
const msg = data.error || t('bookslm.error');
|
||||
assistantMsg.content += (assistantMsg.content ? '\n\n' : '') + '⚠ ' + msg;
|
||||
return;
|
||||
}
|
||||
const chunk = data.token || data.content || '';
|
||||
if (chunk) assistantMsg.content += chunk;
|
||||
if (data.sources) assistantMsg.sources = data.sources;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) handleLine(line);
|
||||
this._renderMessages();
|
||||
}
|
||||
if (buffer) handleLine(buffer);
|
||||
}
|
||||
|
||||
// ── Markdown (self-contained renderer) ──────────────────────────────
|
||||
//
|
||||
// Renders the assistant answer as formatted HTML (headings, lists, tables,
|
||||
@@ -1167,14 +1394,20 @@ class BooksLM {
|
||||
} catch { /* quota / private mode */ }
|
||||
}
|
||||
|
||||
/** Strip transient (large) fields before persisting a conversation. */
|
||||
_persistableMessages() {
|
||||
return this._messages.map(({ confirmation, payload, ...rest }) => rest);
|
||||
}
|
||||
|
||||
_saveHistory() {
|
||||
const persisted = this._persistableMessages();
|
||||
const session = this._currentSession();
|
||||
if (session) {
|
||||
session.messages = this._messages;
|
||||
session.messages = persisted;
|
||||
session.updatedAt = Date.now();
|
||||
if (!session.title) session.title = this._deriveTitle(this._messages);
|
||||
} else {
|
||||
const created = this._createSession(this._messages);
|
||||
const created = this._createSession(persisted);
|
||||
created.title = this._deriveTitle(this._messages);
|
||||
this._sessions.unshift(created);
|
||||
this._currentSessionId = created.id;
|
||||
|
||||
Reference in New Issue
Block a user