First Version
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { LitElement } from 'lit';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
marked.use({ breaks: true, gfm: true });
|
||||
|
||||
/**
|
||||
* An http(s) link whose origin differs from the page's is "external" and
|
||||
* should open in a new tab. Relative paths, hash anchors (e.g. the app's
|
||||
* `#file_viewer?...` routing), and other schemes (mailto:, tel:) are left
|
||||
* untouched so in-app navigation and native handlers keep working.
|
||||
*/
|
||||
function isExternalLink(href) {
|
||||
if (!href) return false;
|
||||
try {
|
||||
const url = new URL(href, window.location.href);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
return url.origin !== window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Open external links in a new tab. `rel` is in DOMPurify's default allow-list;
|
||||
// `target` is whitelisted via ADD_ATTR in renderMarkdown(). Runs once per module load.
|
||||
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
|
||||
if (data.tagName !== 'a' || !node.hasAttribute('href')) return;
|
||||
if (isExternalLink(node.getAttribute('href'))) {
|
||||
node.setAttribute('target', '_blank');
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
export function renderMarkdown(text) {
|
||||
// `target` is not in DOMPurify's default attribute allow-list, so the
|
||||
// external-link hook above needs it whitelisted here to survive sanitization.
|
||||
return DOMPurify.sanitize(marked.parse(text ?? ''), { ADD_ATTR: ['target'] });
|
||||
}
|
||||
|
||||
// Disable Shadow DOM so Bootstrap CSS flows through naturally.
|
||||
export class LightElement extends LitElement {
|
||||
createRenderRoot() { return this; }
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
import { LightElement } from './base.js';
|
||||
|
||||
// Slash commands handled entirely server-side: they reply with a `Done` and never
|
||||
// echo back as a `user_message`, so they are the only commands rendered
|
||||
// optimistically on send. Everything else — custom commands and unknown ones — is
|
||||
// telnet-style: the bubble appears only when the backend persists it and re-emits a
|
||||
// `user_message` (custom commands carry their typed form as the echoed content).
|
||||
// (`/new` and `/clear` are intercepted earlier and never reach the echo.)
|
||||
const SYSTEM_SLASH_COMMANDS = new Set([
|
||||
'/help', '/models', '/model', '/context', '/cost',
|
||||
'/compact', '/resettools', '/sethome',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Base class for chat UI components (desktop copilot, mobile chat page).
|
||||
*
|
||||
* Contains all WebSocket logic, message state, and approval handling.
|
||||
* Subclasses implement the render() method and override the DOM hooks:
|
||||
* - _scrollToBottom()
|
||||
* - _getInputContent() → returns current input value
|
||||
* - _clearInput() → empties the input
|
||||
* - _onMessagePushed(item) → called after each push (scroll, focus, etc.)
|
||||
*/
|
||||
export class ChatSession extends LightElement {
|
||||
static properties = {
|
||||
_messages: { state: true },
|
||||
_waiting: { state: true },
|
||||
_expanded: { state: true },
|
||||
_providers: { state: true },
|
||||
_selectedClient: { state: true },
|
||||
_rejectingId: { state: true },
|
||||
_rejectNote: { state: true },
|
||||
_clarificationAnswer: { state: true },
|
||||
// Voice recording state (shared by every chat surface).
|
||||
_hasTranscribe: { state: true },
|
||||
_recording: { state: true },
|
||||
// Pending attachments for the message being composed (shown as chips above
|
||||
// the textarea; uploaded to disk on selection, sent with the next message).
|
||||
_attachments: { state: true },
|
||||
};
|
||||
|
||||
// Live events whose arrival implies a turn is in flight (used to restore the
|
||||
// STOP button when reconnecting mid-turn).
|
||||
static _STREAMING_EVENTS = new Set([
|
||||
'thinking', 'tool_start', 'agent_start', 'pending_write', 'approval_required',
|
||||
]);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._messages = [];
|
||||
this._waiting = false;
|
||||
this._expanded = new Set();
|
||||
this._ws = null;
|
||||
this._providers = [];
|
||||
this._selectedClient = null;
|
||||
this._rejectingId = null;
|
||||
this._rejectNote = '';
|
||||
this._clarificationAnswer = '';
|
||||
// Runtime-selected source. When null, falls back to the static `_wsSource`.
|
||||
// Lets a single chat component switch between sessions (e.g. copilot tabs).
|
||||
this._activeSource = null;
|
||||
// Voice recording state. Shared so every surface (desktop copilot + mobile
|
||||
// chat) can expose the same mic button. The desktop-only Ctrl+Space push-
|
||||
// to-talk shortcut is wired in `app-copilot`; `_shortcutRecording` tracks
|
||||
// whether a recording session was started by that shortcut.
|
||||
this._hasTranscribe = false;
|
||||
this._recording = false;
|
||||
this._shortcutRecording = false;
|
||||
this._mediaRecorder = null;
|
||||
this._audioChunks = [];
|
||||
// Each entry: { name, path, mimetype, filesize, uploading? }. While an upload
|
||||
// is in flight the entry has `uploading: true` and no `path` yet.
|
||||
this._attachments = [];
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Fire-and-forget: availability of a transcription provider determines
|
||||
// whether the mic button is rendered at all.
|
||||
this._checkTranscribe();
|
||||
await Promise.all([this._loadProviders(), this._loadHistory()]);
|
||||
this._connectWS();
|
||||
}
|
||||
|
||||
// ── Source identity — override in subclass ────────────────────────────────────
|
||||
|
||||
// Static default source for this component. Subclasses override (e.g. 'mobile').
|
||||
get _wsSource() { return 'web'; }
|
||||
|
||||
// Effective source: the runtime-selected one, or the static default.
|
||||
get _source() { return this._activeSource ?? this._wsSource; }
|
||||
|
||||
/**
|
||||
* Switch the live connection to a different source: tear down the current WS,
|
||||
* swap source, reload that source's history, and reconnect. Used to move
|
||||
* between sessions (e.g. General ↔ a project chat) without remounting.
|
||||
*/
|
||||
async _switchSource(source) {
|
||||
if (this._ws) { this._ws.onclose = null; this._ws.close(); this._ws = null; }
|
||||
this._activeSource = source;
|
||||
this._messages = [];
|
||||
this._waiting = false;
|
||||
await this._loadHistory();
|
||||
this._connectWS();
|
||||
}
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
async _loadProviders() {
|
||||
try {
|
||||
const res = await fetch('/api/llm/models/selector');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { models, default: def } = await res.json();
|
||||
this._providers = models;
|
||||
this._selectedClient = def;
|
||||
} catch (e) {
|
||||
console.error('Failed to load LLM models:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async _loadHistory() {
|
||||
try {
|
||||
const res = await fetch(`/api/${this._source}/messages`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const items = await res.json();
|
||||
if (items.length > 0) {
|
||||
this._messages = items;
|
||||
const expanded = new Set(this._expanded);
|
||||
for (const m of items) {
|
||||
if (m.kind === 'tool' && m.status === 'pending') expanded.add(m.tool_call_id);
|
||||
}
|
||||
this._expanded = expanded;
|
||||
this._scrollToBottom();
|
||||
// Set flag so ws.onopen sends a resume if there are pending tools
|
||||
// (approval/clarification waiting) or interrupted tools (status=error+Interrupted).
|
||||
this._hasPendingTools = items.some(
|
||||
m => m.kind === 'tool' && (m.status === 'pending' || (m.status === 'error' && m.error === 'Interrupted.'))
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not load history:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_connectWS() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/api/ws?source=${this._source}`);
|
||||
this._ws = ws;
|
||||
ws.onopen = () => {
|
||||
if (this._hasPendingTools) {
|
||||
ws.send(JSON.stringify({ type: 'resume' }));
|
||||
this._hasPendingTools = false;
|
||||
}
|
||||
};
|
||||
ws.onmessage = (ev) => this._handleServerMsg(JSON.parse(ev.data));
|
||||
ws.onclose = () => setTimeout(() => this._connectWS(), 2000);
|
||||
}
|
||||
|
||||
async _startNewSession() {
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null;
|
||||
this._ws.close();
|
||||
this._ws = null;
|
||||
}
|
||||
this._messages = [];
|
||||
this._waiting = false;
|
||||
try {
|
||||
const res = await fetch(`/api/sessions?source=${this._source}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
this._pushError('Could not clear session: ' + e.message);
|
||||
}
|
||||
this._connectWS();
|
||||
}
|
||||
|
||||
// ── Message handling ──────────────────────────────────────────────────────────
|
||||
|
||||
_handleServerMsg(msg) {
|
||||
console.debug('[WS ←]', msg.type, msg);
|
||||
// Receiving a live streaming event means a turn is active — restore the STOP
|
||||
// button even if we reconnected mid-turn and missed the start. `done`/`error`
|
||||
// reset it below.
|
||||
if (!this._waiting && ChatSession._STREAMING_EVENTS.has(msg.type)) {
|
||||
this._waiting = true;
|
||||
}
|
||||
switch (msg.type) {
|
||||
// Sent on (re)connect: authoritative running state for this session.
|
||||
case 'turn_running':
|
||||
this._waiting = msg.running;
|
||||
break;
|
||||
|
||||
case 'pending_write':
|
||||
this._push({
|
||||
kind: 'pending_write',
|
||||
request_id: msg.request_id,
|
||||
tool_call_id: msg.tool_call_id,
|
||||
path: msg.path,
|
||||
old_content: msg.old_content ?? '',
|
||||
new_content: msg.new_content,
|
||||
status: 'pending',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'thinking':
|
||||
this._push({ kind: 'thinking', message_id: msg.message_id, content: msg.content,
|
||||
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens });
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
this._waiting = false;
|
||||
this._push({ kind: 'assistant', content: msg.content,
|
||||
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens });
|
||||
break;
|
||||
|
||||
case 'tool_start': {
|
||||
// On resume, the server re-emits ToolStart for tools already in history.
|
||||
// Update in place rather than pushing a duplicate card.
|
||||
const existingIdx = this._messages.findIndex(
|
||||
m => (m.kind === 'tool') && m.tool_call_id === msg.tool_call_id
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
this._updateTool(msg.tool_call_id, { status: 'running', result: null, error: null });
|
||||
} else {
|
||||
this._push({
|
||||
kind: 'tool',
|
||||
tool_call_id: msg.tool_call_id,
|
||||
name: msg.name,
|
||||
label_short: msg.label_short,
|
||||
label_full: msg.label_full,
|
||||
path: msg.path,
|
||||
arguments: msg.arguments,
|
||||
status: 'running',
|
||||
result: null,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_done':
|
||||
this._updateTool(msg.tool_call_id, { status: 'done', result: msg.result, result_type: msg.result_type });
|
||||
break;
|
||||
|
||||
case 'tool_error':
|
||||
this._updateTool(msg.tool_call_id, { status: 'error', error: msg.error });
|
||||
break;
|
||||
|
||||
case 'tool_cancelled':
|
||||
// Stopped by the user via /stop — distinct from an error.
|
||||
this._updateTool(msg.tool_call_id, { status: 'cancelled' });
|
||||
break;
|
||||
|
||||
case 'tool_rejected':
|
||||
// Denied by an approval policy or a human — distinct from an error.
|
||||
this._updateTool(msg.tool_call_id, { status: 'rejected', error: msg.reason });
|
||||
break;
|
||||
|
||||
case 'approval_required':
|
||||
this._updateTool(msg.tool_call_id, { status: 'pending', request_id: msg.request_id });
|
||||
this._expanded = new Set([...this._expanded, msg.tool_call_id]);
|
||||
this.updateComplete.then(() => this._scrollToBottom());
|
||||
break;
|
||||
|
||||
case 'approval_resolved': {
|
||||
const { request_id, tool_call_id, approved } = msg;
|
||||
this._updatePendingWrite(request_id, { status: approved ? 'approved' : 'rejected' });
|
||||
if (tool_call_id != null) {
|
||||
if (approved) {
|
||||
this._updateTool(tool_call_id, { status: 'running', request_id: null });
|
||||
} else {
|
||||
this._updateTool(tool_call_id, { status: 'rejected', error: 'Rifiutato.' });
|
||||
}
|
||||
const expanded = new Set(this._expanded);
|
||||
expanded.delete(tool_call_id);
|
||||
this._expanded = expanded;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent_question':
|
||||
// Link the question form to the tool card by updating status + storing request_id.
|
||||
this._updateTool(msg.tool_call_id, {
|
||||
status: 'pending',
|
||||
request_id: msg.request_id,
|
||||
question: msg.question,
|
||||
question_title: msg.title,
|
||||
suggested_answers: msg.suggested_answers ?? [],
|
||||
});
|
||||
this._expanded = new Set([...this._expanded, msg.tool_call_id]);
|
||||
this.updateComplete.then(() => this._scrollToBottom());
|
||||
break;
|
||||
|
||||
case 'agent_start':
|
||||
this._push({
|
||||
kind: 'agent',
|
||||
stack_id: msg.stack_id,
|
||||
agent_id: msg.agent_id,
|
||||
parent_agent_id: msg.parent_agent_id,
|
||||
prompt_preview: msg.prompt_preview,
|
||||
depth: msg.depth,
|
||||
done: false,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'agent_done': {
|
||||
this._updateAgent(msg.stack_id, { done: true });
|
||||
const agentMsg = this._messages.find(m => m.kind === 'agent' && m.stack_id === msg.stack_id);
|
||||
if (agentMsg) {
|
||||
this._push({
|
||||
kind: 'agent_end',
|
||||
agent_id: msg.agent_id,
|
||||
parent_agent_id: msg.parent_agent_id,
|
||||
result_preview: msg.result_preview,
|
||||
depth: agentMsg.depth,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'truncated':
|
||||
this._pushError(`Risposta troncata dal limite di token (↓${msg.output_tokens?.toLocaleString() ?? '?'} tok).`);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this._waiting = false;
|
||||
this._pushError(msg.message);
|
||||
break;
|
||||
|
||||
case 'file_changed':
|
||||
window.dispatchEvent(new CustomEvent('file-changed', { detail: { path: msg.path } }));
|
||||
break;
|
||||
|
||||
case 'open_file': {
|
||||
// Agent-driven file open. Every kind — HTML included — routes through the
|
||||
// file-viewer page, which renders HTML live in an origin-isolated iframe.
|
||||
const p = msg.path ?? '';
|
||||
if (p && typeof window.openFile === 'function') window.openFile(p);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'model_fallback':
|
||||
this._push({ kind: 'info', content: `⚡ Model fallback: ${msg.from} → ${msg.to}` });
|
||||
break;
|
||||
|
||||
case 'user_message':
|
||||
// Telnet-style echo: the backend emits this when the message is persisted
|
||||
// to history, so we render the bubble here — for the sending client and
|
||||
// every other client alike. No dedup needed: regular messages are never
|
||||
// rendered optimistically (only slash commands are, and those are never
|
||||
// echoed). `message_id` is the real chat_history row id.
|
||||
this._push({
|
||||
kind: 'user',
|
||||
content: msg.content,
|
||||
attachments: msg.attachments ?? [],
|
||||
message_id: msg.message_id,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'new_session':
|
||||
this._messages = [];
|
||||
this._waiting = false;
|
||||
break;
|
||||
|
||||
case 'client_selected':
|
||||
// Backend is the single source of truth for the pinned model. Updates
|
||||
// arrive here regardless of which client (dropdown, /model command,
|
||||
// another tab) originated the change — so the dropdown/select stays
|
||||
// in sync. We set the field directly; Lit re-renders because
|
||||
// `_selectedClient` is `state: true`.
|
||||
this._selectedClient = msg.client;
|
||||
break;
|
||||
|
||||
case 'llm_failed':
|
||||
this._waiting = false;
|
||||
this._pushError(`LLM unavailable. Tried: ${msg.tried.join(', ')}. ${msg.last_error}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_push(item) {
|
||||
console.debug('[push]', item.kind, item);
|
||||
this._messages = [...this._messages, item];
|
||||
this._onMessagePushed(item);
|
||||
}
|
||||
|
||||
_pushError(text) {
|
||||
this._push({ kind: 'error', content: text });
|
||||
}
|
||||
|
||||
_updateAgent(stack_id, patch) {
|
||||
const idx = this._messages.findIndex(m => m.kind === 'agent' && m.stack_id === stack_id);
|
||||
if (idx < 0) return;
|
||||
const updated = [...this._messages];
|
||||
updated[idx] = { ...updated[idx], ...patch };
|
||||
this._messages = updated;
|
||||
}
|
||||
|
||||
_updateTool(tool_call_id, patch) {
|
||||
const idx = this._messages.findIndex(
|
||||
m => (m.kind === 'tool' || m.kind === 'pending_write') && m.tool_call_id === tool_call_id
|
||||
);
|
||||
if (idx < 0) return;
|
||||
const updated = [...this._messages];
|
||||
updated[idx] = { ...updated[idx], ...patch };
|
||||
this._messages = updated;
|
||||
}
|
||||
|
||||
_updatePendingWrite(request_id, patch) {
|
||||
const idx = this._messages.findIndex(
|
||||
m => m.kind === 'pending_write' && m.request_id === request_id
|
||||
);
|
||||
if (idx < 0) return;
|
||||
// Once resolved (approved or rejected) remove the block entirely —
|
||||
// the tool card already shows the outcome.
|
||||
if (patch.status === 'approved' || patch.status === 'rejected') {
|
||||
this._messages = this._messages.filter((_, i) => i !== idx);
|
||||
return;
|
||||
}
|
||||
const updated = [...this._messages];
|
||||
updated[idx] = { ...updated[idx], ...patch };
|
||||
this._messages = updated;
|
||||
}
|
||||
|
||||
// ── User input ────────────────────────────────────────────────────────────────
|
||||
|
||||
async _send() {
|
||||
const content = this._getInputContent();
|
||||
// Text is required; attachments are a complement, never sent on their own.
|
||||
// Sending is allowed while a turn is in flight: the message is queued and
|
||||
// injected into the running turn at its next round boundary.
|
||||
if (!content) return;
|
||||
// Don't send while an attachment is still streaming to disk, or its path
|
||||
// would be missing from the message.
|
||||
if (this._attachments.some(a => a.uploading)) return;
|
||||
this._clearInput();
|
||||
|
||||
if (content === '/new' || content === '/clear') {
|
||||
this._attachments = [];
|
||||
await this._startNewSession();
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip client-only fields; the server persists these as message metadata.
|
||||
const attachments = this._attachments.map(({ name, path, mimetype, filesize }) =>
|
||||
({ name, path, mimetype, filesize }));
|
||||
this._attachments = [];
|
||||
|
||||
// System slash commands reply with a `Done` and never echo back as a
|
||||
// `user_message`, so render them optimistically. Regular messages and custom
|
||||
// slash commands use telnet-style echo: no local push — the bubble appears only
|
||||
// when the backend persists the message and sends it back as a `user_message`
|
||||
// event (for a custom command, carrying the typed form as its content), placing
|
||||
// it correctly (e.g. after the current round's tools when injected mid-turn).
|
||||
if (SYSTEM_SLASH_COMMANDS.has(content.split(/\s+/)[0])) {
|
||||
this._push({ kind: 'user', content, attachments });
|
||||
}
|
||||
this._waiting = true;
|
||||
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ content, attachments }));
|
||||
} else {
|
||||
this._pushError('Not connected — reconnecting, please retry.');
|
||||
this._waiting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Attachments ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload the given files to `data/uploads/{session}/` and add them as chips.
|
||||
* Each file is streamed to disk server-side; while in flight its chip shows a
|
||||
* spinner. Accepts a FileList or array of File.
|
||||
*/
|
||||
async _addFiles(files) {
|
||||
const list = Array.from(files || []).filter(Boolean);
|
||||
if (list.length === 0) return;
|
||||
|
||||
// Optimistic placeholders so the chips appear immediately.
|
||||
const pending = list.map(f => ({ name: f.name, filesize: f.size, mimetype: f.type, uploading: true }));
|
||||
this._attachments = [...this._attachments, ...pending];
|
||||
|
||||
const form = new FormData();
|
||||
for (const f of list) form.append('files', f, f.name);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/${this._source}/uploads`, { method: 'POST', body: form });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const saved = await res.json(); // [{ name, path, mimetype, filesize }]
|
||||
// Replace the placeholders with the saved entries (preserve other chips).
|
||||
this._attachments = this._attachments.filter(a => !pending.includes(a)).concat(saved);
|
||||
} catch (e) {
|
||||
console.error('upload failed:', e);
|
||||
// Drop the failed placeholders and surface the error.
|
||||
this._attachments = this._attachments.filter(a => !pending.includes(a));
|
||||
this._pushError('Upload failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
_removeAttachment(i) {
|
||||
this._attachments = this._attachments.filter((_, idx) => idx !== i);
|
||||
}
|
||||
|
||||
/** Handler for a paste event: uploads any files on the clipboard. */
|
||||
_onPaste(e) {
|
||||
const files = e.clipboardData?.files;
|
||||
if (files && files.length) {
|
||||
e.preventDefault();
|
||||
this._addFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handler for a drop event on the composer: uploads the dropped files. */
|
||||
_onDrop(e) {
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length) {
|
||||
e.preventDefault();
|
||||
this._addFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a client (model) for the current source. Mirrors the state locally for
|
||||
* instant feedback, then notifies the backend, which is the single source of
|
||||
* truth — it broadcasts `client_selected` back to every client of the source
|
||||
* (this tab included), so the dropdown/select re-syncs from authoritative
|
||||
* state. Pass `'auto'` to clear the pin.
|
||||
*/
|
||||
_selectClient(client) {
|
||||
this._selectedClient = client;
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ type: 'select_client', client }));
|
||||
}
|
||||
}
|
||||
|
||||
_cancel() {
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ type: 'cancel' }));
|
||||
}
|
||||
this._waiting = false;
|
||||
}
|
||||
|
||||
// ── Approval — pending_write (WS) ─────────────────────────────────────────────
|
||||
|
||||
_approve(msg) {
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ type: 'approve_write', request_id: msg.request_id }));
|
||||
}
|
||||
this._updatePendingWrite(msg.request_id, { status: 'approved' });
|
||||
}
|
||||
|
||||
_approveWriteBypass(msg, bypassSecs) {
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ type: 'approve_write', request_id: msg.request_id, bypass_secs: bypassSecs }));
|
||||
}
|
||||
this._updatePendingWrite(msg.request_id, { status: 'approved' });
|
||||
}
|
||||
|
||||
_startReject(msg) {
|
||||
this._rejectingId = msg.request_id;
|
||||
this._rejectNote = '';
|
||||
}
|
||||
|
||||
_confirmReject(msg) {
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ type: 'reject_write', request_id: msg.request_id, note: this._rejectNote }));
|
||||
}
|
||||
this._updatePendingWrite(msg.request_id, { status: 'rejected' });
|
||||
this._rejectingId = null;
|
||||
}
|
||||
|
||||
// ── Approval — tool (WS, live) ────────────────────────────────────────────────
|
||||
|
||||
_approveWsTool(msg) {
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ type: 'approve_tool', request_id: msg.request_id }));
|
||||
}
|
||||
this._updateTool(msg.tool_call_id, { status: 'running', request_id: null });
|
||||
this._rejectingId = null;
|
||||
}
|
||||
|
||||
_approveWsToolBypass(msg, bypassSecs) {
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ type: 'approve_tool', request_id: msg.request_id, bypass_secs: bypassSecs }));
|
||||
}
|
||||
this._updateTool(msg.tool_call_id, { status: 'running', request_id: null });
|
||||
this._rejectingId = null;
|
||||
}
|
||||
|
||||
_rejectWsTool(msg) {
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ type: 'reject_tool', request_id: msg.request_id, note: this._rejectNote }));
|
||||
}
|
||||
this._updateTool(msg.tool_call_id, { status: 'rejected', error: "Rifiutato dall'utente." });
|
||||
this._rejectingId = null;
|
||||
}
|
||||
|
||||
// ── Approval — tool (REST, from history) ─────────────────────────────────────
|
||||
|
||||
async _approveTool(msg) {
|
||||
this._updateTool(msg.tool_call_id, { status: 'running' });
|
||||
try {
|
||||
const res = await fetch(`/api/tools/${msg.tool_call_id}/resolve`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'approve' }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this._updateTool(msg.tool_call_id, { status: 'error', error: `Approval failed: ${await res.text()}` });
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
this._updateTool(msg.tool_call_id, { status: data.status, result: data.result, result_type: data.result_type });
|
||||
if (this._ws?.readyState === WebSocket.OPEN) this._ws.send(JSON.stringify({ type: 'resume' }));
|
||||
} catch (e) {
|
||||
this._updateTool(msg.tool_call_id, { status: 'error', error: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
async _rejectTool(msg) {
|
||||
this._updateTool(msg.tool_call_id, { status: 'running' });
|
||||
try {
|
||||
const res = await fetch(`/api/tools/${msg.tool_call_id}/resolve`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'reject', note: this._rejectNote }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this._updateTool(msg.tool_call_id, { status: 'error', error: `Rejection failed: ${await res.text()}` });
|
||||
return;
|
||||
}
|
||||
this._updateTool(msg.tool_call_id, { status: 'error', error: 'Rejected by user.' });
|
||||
this._rejectingId = null;
|
||||
if (this._ws?.readyState === WebSocket.OPEN) this._ws.send(JSON.stringify({ type: 'resume' }));
|
||||
} catch (e) {
|
||||
this._updateTool(msg.tool_call_id, { status: 'error', error: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clarification ─────────────────────────────────────────────────────────────
|
||||
|
||||
_answerQuestion(msg) {
|
||||
const answer = this._clarificationAnswer.trim();
|
||||
if (!answer) return;
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ type: 'answer_question', request_id: msg.request_id, answer }));
|
||||
}
|
||||
this._updateTool(msg.tool_call_id, { status: 'running', request_id: null });
|
||||
this._clarificationAnswer = '';
|
||||
}
|
||||
|
||||
// ── DOM hooks (override in subclass) ─────────────────────────────────────────
|
||||
|
||||
/** Called after every _push(). Override to handle scrolling, focus, etc. */
|
||||
_onMessagePushed(_item) {}
|
||||
|
||||
/** Returns the chat input textarea element. Subclasses must override. */
|
||||
_inputEl() { return null; }
|
||||
|
||||
/** Returns the current value of the chat input. */
|
||||
_getInputContent() { return this._inputEl()?.value.trim() ?? ''; }
|
||||
|
||||
/** Clears the chat input and resets its auto-resize height. */
|
||||
_clearInput() {
|
||||
const el = this._inputEl();
|
||||
if (!el) return;
|
||||
el.value = '';
|
||||
el.style.height = 'auto';
|
||||
}
|
||||
|
||||
/** Auto-resizes a textarea to fit its content (capped by CSS max-height). */
|
||||
_autoResize(el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
/** Scrolls the message list to the bottom. */
|
||||
_scrollToBottom() {}
|
||||
|
||||
// ── Voice recording (shared by every chat surface) ────────────────────────────
|
||||
|
||||
async _checkTranscribe() {
|
||||
try {
|
||||
const r = await fetch('/api/transcribe/has');
|
||||
this._hasTranscribe = r.status === 204;
|
||||
} catch {
|
||||
this._hasTranscribe = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _startRecording(fromShortcut = false) {
|
||||
if (this._recording) return;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this._audioChunks = [];
|
||||
this._shortcutRecording = fromShortcut;
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: MediaRecorder.isTypeSupported('audio/webm')
|
||||
? 'audio/webm'
|
||||
: '';
|
||||
|
||||
this._mediaRecorder = mimeType
|
||||
? new MediaRecorder(stream, { mimeType })
|
||||
: new MediaRecorder(stream);
|
||||
|
||||
this._mediaRecorder.addEventListener('dataavailable', e => {
|
||||
if (e.data.size > 0) this._audioChunks.push(e.data);
|
||||
});
|
||||
|
||||
this._mediaRecorder.addEventListener('stop', () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
this._submitAudio();
|
||||
});
|
||||
|
||||
this._mediaRecorder.start();
|
||||
this._recording = true;
|
||||
} catch (err) {
|
||||
console.error('mic error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_stopRecording() {
|
||||
if (!this._recording || !this._mediaRecorder) return;
|
||||
this._mediaRecorder.stop();
|
||||
this._recording = false;
|
||||
}
|
||||
|
||||
/** Toggle button handler: start or stop a recording (button-initiated). */
|
||||
_toggleRecording() {
|
||||
if (this._recording) {
|
||||
this._shortcutRecording = false;
|
||||
this._stopRecording();
|
||||
} else {
|
||||
this._startRecording(false);
|
||||
}
|
||||
}
|
||||
|
||||
async _submitAudio() {
|
||||
if (this._audioChunks.length === 0) return;
|
||||
|
||||
const mimeType = this._mediaRecorder?.mimeType ?? 'audio/webm';
|
||||
const blob = new Blob(this._audioChunks, { type: mimeType });
|
||||
// Derive file extension from mimeType, e.g. "audio/webm;codecs=opus" → "webm"
|
||||
const ext = mimeType.split('/')[1]?.split(';')[0] ?? 'webm';
|
||||
|
||||
const form = new FormData();
|
||||
form.append('audio', blob, `recording.${ext}`);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/transcribe/audio', { method: 'POST', body: form });
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const { text } = await resp.json();
|
||||
if (text) {
|
||||
const ta = this._inputEl();
|
||||
if (ta) {
|
||||
ta.value = (ta.value ? ta.value + ' ' : '') + text;
|
||||
this._autoResize(ta);
|
||||
ta.focus();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('transcription error:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Singleton client for the `/api/file/watch` WebSocket.
|
||||
*
|
||||
* One persistent connection for the whole app. Multi-subscriber: if several
|
||||
* components ask to watch the same path, only one subscribe message is sent
|
||||
* over the wire; the OS watcher is shared. Unsubscribe ref-counts down and
|
||||
* only sends `unsubscribe` when the last consumer for a path goes away.
|
||||
*
|
||||
* Auto-reconnects on close (2 s backoff) and re-issues every active
|
||||
* subscription on reconnect, so consumers don't have to handle disconnects.
|
||||
*
|
||||
* Usage:
|
||||
* import { fileWatcher } from '../lib/file-watcher.js';
|
||||
* const unsub = await fileWatcher.watch('docs/index.md', (path) => { ... });
|
||||
* unsub(); // stop watching
|
||||
*/
|
||||
class FileWatcher {
|
||||
constructor() {
|
||||
this._ws = null;
|
||||
this._subscriptions = new Map(); // path -> Set<callback>
|
||||
this._reconnectTimer = null;
|
||||
this._connectPromise = null;
|
||||
}
|
||||
|
||||
_ensureConnected() {
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN) return Promise.resolve();
|
||||
if (this._connectPromise) return this._connectPromise;
|
||||
|
||||
this._connectPromise = new Promise((resolve, reject) => {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/api/file/watch`);
|
||||
this._ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
this._connectPromise = null;
|
||||
// Re-subscribe everything (covers both first connect and reconnect).
|
||||
for (const path of this._subscriptions.keys()) {
|
||||
ws.send(JSON.stringify({ op: 'subscribe', path }));
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(e.data); } catch { return; }
|
||||
if (msg.type === 'changed') {
|
||||
const cbs = this._subscriptions.get(msg.path);
|
||||
if (cbs) cbs.forEach(cb => { try { cb(msg.path); } catch { /* swallow */ } });
|
||||
}
|
||||
// 'subscribed' / 'unsubscribed' / 'error' acks are informational;
|
||||
// we don't currently surface them to consumers.
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (this._connectPromise) {
|
||||
this._connectPromise = null;
|
||||
reject(new Error('file-watch WS error'));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this._ws = null;
|
||||
this._connectPromise = null;
|
||||
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
this._reconnectTimer = null;
|
||||
this._ensureConnected().catch(() => { /* silent retry */ });
|
||||
}, 2000);
|
||||
};
|
||||
});
|
||||
return this._connectPromise;
|
||||
}
|
||||
|
||||
async watch(path, cb) {
|
||||
await this._ensureConnected();
|
||||
let cbs = this._subscriptions.get(path);
|
||||
const isNew = !cbs;
|
||||
if (!cbs) {
|
||||
cbs = new Set();
|
||||
this._subscriptions.set(path, cbs);
|
||||
}
|
||||
cbs.add(cb);
|
||||
if (isNew && this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ op: 'subscribe', path }));
|
||||
}
|
||||
return () => this.unwatch(path, cb);
|
||||
}
|
||||
|
||||
unwatch(path, cb) {
|
||||
const cbs = this._subscriptions.get(path);
|
||||
if (!cbs) return;
|
||||
cbs.delete(cb);
|
||||
if (cbs.size === 0) {
|
||||
this._subscriptions.delete(path);
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ op: 'unsubscribe', path }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fileWatcher = new FileWatcher();
|
||||
@@ -0,0 +1,397 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||
import { renderMarkdown } from './base.js';
|
||||
|
||||
/**
|
||||
* InboxMixin — shared fetch, action, and render logic for the agent inbox.
|
||||
* Used by AgentInboxPage (full page) and HomePage (embedded section).
|
||||
*/
|
||||
export const InboxMixin = (Base) => class extends Base {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
...super.properties,
|
||||
_inboxData: { state: true },
|
||||
_inboxError: { state: true },
|
||||
_inboxLoading: { state: true },
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._inboxData = null;
|
||||
this._inboxError = null;
|
||||
this._inboxLoading = false;
|
||||
this._expanded = new Set();
|
||||
this._bypassOpen = new Set();
|
||||
}
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async _loadInbox() {
|
||||
try {
|
||||
const res = await fetch('/api/inbox');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
this._inboxData = await res.json();
|
||||
this._inboxError = null;
|
||||
window.dispatchEvent(new CustomEvent('inbox-count', { detail: { count: this._inboxData.total } }));
|
||||
} catch (e) {
|
||||
this._inboxError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
async _resolveApproval(requestId, action, note = '', bypassSecs = null, bypassScope = null, toolCallId = null) {
|
||||
try {
|
||||
const body = { action, note };
|
||||
if (bypassSecs !== null) {
|
||||
body.bypass_secs = bypassSecs;
|
||||
body.bypass_scope = bypassScope;
|
||||
}
|
||||
// Live items resolve by request_id (and support bypass); DB-persisted
|
||||
// (post-restart) items carry request_id 0 → resolve by the durable,
|
||||
// source-agnostic tool_call_id (bypass buttons are hidden for them).
|
||||
const url = requestId
|
||||
? `/api/inbox/approvals/${requestId}/resolve`
|
||||
: `/api/tools/${toolCallId}/resolve`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._loadInbox();
|
||||
} catch (e) {
|
||||
this._inboxError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
_rejectWithNote(requestId, toolCallId = null) {
|
||||
const note = prompt('Rejection reason (optional):') ?? '';
|
||||
this._resolveApproval(requestId, 'reject', note, null, null, toolCallId);
|
||||
}
|
||||
|
||||
/** Approve + set a timed or session bypass scoped to the tool's category or MCP server. */
|
||||
_approveWithBypass(item, bypassSecs) {
|
||||
const scope = item.tool_category ? 'category'
|
||||
: item.mcp_server ? 'mcp_server'
|
||||
: 'all';
|
||||
this._resolveApproval(item.request_id, 'approve', '', bypassSecs, scope);
|
||||
}
|
||||
|
||||
/** Human-readable bypass scope label, e.g. "filesystem" or "Gmail". */
|
||||
_bypassLabel(item) {
|
||||
if (item.tool_category) return item.tool_category;
|
||||
if (item.mcp_server) return item.mcp_server;
|
||||
return 'session';
|
||||
}
|
||||
|
||||
async _resolveClarification(requestId, inputEl) {
|
||||
const answer = inputEl.value.trim();
|
||||
if (!answer) return;
|
||||
try {
|
||||
const res = await fetch(`/api/inbox/clarifications/${requestId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ answer }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._loadInbox();
|
||||
} catch (e) {
|
||||
this._inboxError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a server-initiated MCP elicitation. On `accept` with a field, the
|
||||
* input value is packed into `content` ({ [field]: value }); the secret is
|
||||
* sent once and never echoed back into the UI. `decline`/`cancel` send no value.
|
||||
*/
|
||||
async _resolveElicitation(item, action, inputEl) {
|
||||
let content = null;
|
||||
if (action === 'accept' && item.field_name) {
|
||||
content = { [item.field_name]: inputEl ? inputEl.value : '' };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/inbox/elicitations/${item.request_id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, content }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await this._loadInbox();
|
||||
} catch (e) {
|
||||
this._inboxError = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
_toggleRaw(id) {
|
||||
if (this._expanded.has(id)) this._expanded.delete(id);
|
||||
else this._expanded.add(id);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_toggleBypassMenu(id) {
|
||||
if (this._bypassOpen.has(id)) this._bypassOpen.delete(id);
|
||||
else this._bypassOpen.add(id);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_fmt(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
_keyArgs(args) {
|
||||
const entries = [];
|
||||
for (const key of ['path', 'command', 'url', 'origin', 'destination', 'name', 'message', 'query']) {
|
||||
if (args[key] !== undefined) {
|
||||
let val = args[key];
|
||||
if (typeof val === 'object') val = JSON.stringify(val);
|
||||
entries.push({ key, value: String(val) });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ── Card renderers ────────────────────────────────────────────────────────
|
||||
|
||||
_renderApprovalCard(item) {
|
||||
const id = `raw-${item.request_id}`;
|
||||
const open = this._expanded.has(id);
|
||||
const label = item.context_label ?? item.source;
|
||||
const args = item.arguments ?? {};
|
||||
const keyArgs = this._keyArgs(args);
|
||||
const rawJson = JSON.stringify(args, null, 2);
|
||||
|
||||
return html`
|
||||
<div class="inbox-card approval-card">
|
||||
<div class="inbox-card-header">
|
||||
<span class="badge bg-warning text-dark">Approval</span>
|
||||
<span class="inbox-card-origin" title="${label}">${label}</span>
|
||||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-body">
|
||||
<div class="inbox-tool-name">
|
||||
<i class="bi bi-tools"></i>
|
||||
<strong>${item.tool_name}</strong>
|
||||
<span class="inbox-agent-tag">
|
||||
<i class="bi bi-person"></i> ${item.agent_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
${keyArgs.length > 0 ? html`
|
||||
<div class="inbox-args-structured">
|
||||
${keyArgs.map(kv => html`
|
||||
<div class="inbox-arg-row">
|
||||
<span class="inbox-arg-key">${kv.key}</span>
|
||||
<span class="inbox-arg-value">${kv.value}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<button class="inbox-args-toggle" @click=${() => this._toggleRaw(id)}>
|
||||
<i class="bi ${open ? 'bi-chevron-up' : 'bi-chevron-down'}"></i>
|
||||
${open ? 'Hide raw JSON' : 'Show raw JSON'}
|
||||
</button>
|
||||
<pre class="inbox-args-raw ${open ? 'open' : ''}">${rawJson}</pre>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-footer approval-footer">
|
||||
<button class="btn btn-success"
|
||||
@click=${() => this._resolveApproval(item.request_id, 'approve', '', null, null, item.tool_call_id)}>
|
||||
<i class="bi bi-check-lg"></i> Approve
|
||||
</button>
|
||||
<button class="btn btn-outline-danger"
|
||||
@click=${() => this._rejectWithNote(item.request_id, item.tool_call_id)}>
|
||||
<i class="bi bi-x-lg"></i> Reject
|
||||
</button>
|
||||
|
||||
${item.request_id ? html`
|
||||
<div class="inbox-bypass-wrap">
|
||||
<button class="btn btn-outline-secondary"
|
||||
@click=${() => this._toggleBypassMenu(id)}>
|
||||
<i class="bi bi-clock-history"></i> ×${this._bypassLabel(item)} ▾
|
||||
</button>
|
||||
<div class="inbox-bypass-menu ${this._bypassOpen.has(id) ? 'open' : ''}">
|
||||
<button @click=${() => { this._bypassOpen.delete(id); this._approveWithBypass(item, 15 * 60); }}>
|
||||
15 min
|
||||
</button>
|
||||
<button @click=${() => { this._bypassOpen.delete(id); this._approveWithBypass(item, 60 * 60); }}>
|
||||
1 ora
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline-secondary"
|
||||
@click=${() => this._approveWithBypass(item, 0)}
|
||||
title="Approva e non chiedere più per questa sessione">
|
||||
<i class="bi bi-shield-check"></i> Sessione
|
||||
</button>
|
||||
` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderClarificationCard(item) {
|
||||
const label = item.context_label ?? item.source;
|
||||
|
||||
return html`
|
||||
<div class="inbox-card clarification-card">
|
||||
<div class="inbox-card-header">
|
||||
<span class="badge bg-info text-dark">Question</span>
|
||||
<span class="inbox-card-origin" title="${label}">${label}</span>
|
||||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-body">
|
||||
<div class="inbox-card-title">${item.title}</div>
|
||||
<div class="inbox-question copilot-markdown">${unsafeHTML(renderMarkdown(item.question))}</div>
|
||||
|
||||
${item.suggested_answers?.length ? html`
|
||||
<div class="inbox-chips">
|
||||
${item.suggested_answers.map(a => html`
|
||||
<button class="inbox-chip"
|
||||
@click=${(e) => {
|
||||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-answer-input');
|
||||
if (inp) { inp.value = a; inp.focus(); }
|
||||
}}>
|
||||
${a}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="inbox-answer-area">
|
||||
<textarea class="inbox-answer-input" rows="2"
|
||||
placeholder="Your answer…"
|
||||
@keydown=${(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this._resolveClarification(item.request_id, e.target);
|
||||
}
|
||||
}}></textarea>
|
||||
<button class="inbox-answer-send"
|
||||
@click=${(e) => {
|
||||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-answer-input');
|
||||
if (inp) this._resolveClarification(item.request_id, inp);
|
||||
}}>
|
||||
<i class="bi bi-send"></i> Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_renderElicitationCard(item) {
|
||||
const masked = item.sensitive;
|
||||
const confirm = item.is_confirmation;
|
||||
|
||||
return html`
|
||||
<div class="inbox-card elicitation-card">
|
||||
<div class="inbox-card-header">
|
||||
<span class="badge bg-secondary">
|
||||
<i class="bi ${masked ? 'bi-shield-lock' : 'bi-question-circle'}"></i>
|
||||
${confirm ? 'Conferma' : 'Input'}
|
||||
</span>
|
||||
<span class="inbox-card-origin" title="${item.server_name}">${item.server_name}</span>
|
||||
<span class="inbox-card-time">${this._fmt(item.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-body">
|
||||
<div class="inbox-question">${item.message}</div>
|
||||
|
||||
${confirm ? nothing : html`
|
||||
<div class="inbox-answer-area">
|
||||
<input class="inbox-answer-input inbox-secret-input"
|
||||
type="${masked ? 'password' : 'text'}"
|
||||
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
|
||||
placeholder="${masked ? '••••••••' : 'Value…'}"
|
||||
@keydown=${(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this._resolveElicitation(item, 'accept', e.target);
|
||||
}
|
||||
}}>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="inbox-card-footer approval-footer">
|
||||
<button class="btn btn-success"
|
||||
@click=${(e) => {
|
||||
const inp = e.target.closest('.inbox-card')?.querySelector('.inbox-secret-input');
|
||||
this._resolveElicitation(item, 'accept', inp);
|
||||
}}>
|
||||
<i class="bi bi-check-lg"></i> ${confirm ? 'Conferma' : 'Invia'}
|
||||
</button>
|
||||
<button class="btn btn-outline-danger"
|
||||
@click=${() => this._resolveElicitation(item, 'decline', null)}>
|
||||
<i class="bi bi-x-lg"></i> Rifiuta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Section renderer (used by both full page and home embed) ─────────────
|
||||
|
||||
_renderInboxSection() {
|
||||
const approvals = this._inboxData?.approvals ?? [];
|
||||
const clarifications = this._inboxData?.clarifications ?? [];
|
||||
const elicitations = this._inboxData?.elicitations ?? [];
|
||||
const total = approvals.length + clarifications.length + elicitations.length;
|
||||
|
||||
return html`
|
||||
${this._inboxError ? html`
|
||||
<div class="alert alert-danger mx-3 mt-3">${this._inboxError}</div>
|
||||
` : nothing}
|
||||
|
||||
${total === 0 ? html`
|
||||
<div class="inbox-empty">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<p>No pending requests</p>
|
||||
</div>
|
||||
` : html`
|
||||
<div class="inbox-grid">
|
||||
${approvals.length > 0 ? html`
|
||||
<div class="inbox-section-header">
|
||||
<h6>Approvals</h6>
|
||||
<span class="badge bg-warning text-dark">${approvals.length}</span>
|
||||
<span class="section-line"></span>
|
||||
</div>
|
||||
${approvals.map(item => this._renderApprovalCard(item))}
|
||||
` : nothing}
|
||||
|
||||
${clarifications.length > 0 ? html`
|
||||
<div class="inbox-section-header">
|
||||
<h6>Questions</h6>
|
||||
<span class="badge bg-info text-dark">${clarifications.length}</span>
|
||||
<span class="section-line"></span>
|
||||
</div>
|
||||
${clarifications.map(item => this._renderClarificationCard(item))}
|
||||
` : nothing}
|
||||
|
||||
${elicitations.length > 0 ? html`
|
||||
<div class="inbox-section-header">
|
||||
<h6>Secrets</h6>
|
||||
<span class="badge bg-secondary">${elicitations.length}</span>
|
||||
<span class="section-line"></span>
|
||||
</div>
|
||||
${elicitations.map(item => this._renderElicitationCard(item))}
|
||||
` : nothing}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Global file-opener helper.
|
||||
*
|
||||
* `window.openFile(path)` is the single entry point for "show this file to the
|
||||
* user in the file-viewer page". It navigates to
|
||||
* `#file_viewer?path=<encodeURIComponent(path)>`, which the hash router in
|
||||
* `sidebar.js` resolves to the `<file-viewer-page>` element. Back/forward
|
||||
* browser navigation works naturally.
|
||||
*
|
||||
* Components that want to open a file should call `openFile(path)` rather than
|
||||
* set the hash directly — this keeps the URL format in one place.
|
||||
*
|
||||
* Agent-driven opening (the future `show_file_to_user` tool) will set the same
|
||||
* hash from the WS payload, so manual and agent-driven paths funnel together.
|
||||
*/
|
||||
export function openFile(path) {
|
||||
if (!path) return;
|
||||
location.hash = `file_viewer?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
window.openFile = openFile;
|
||||
Reference in New Issue
Block a user