All checks were successful
Beta Release / beta (push) Successful in 1m25s
- Remove Firefox build support (CI, Makefile, wxt config) - Fix chrome.alarms undefined error (add 'alarms' permission) - Add Chat tab to side panel connected to Studio API (/api/chat) - Streaming SSE, tool calls, code blocks, thinking display - Shared chat history with desktop Studio - New lib/api.js client for extension chat endpoints 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
448 lines
16 KiB
JavaScript
448 lines
16 KiB
JavaScript
import '../../styles/panel.css';
|
|
import { getServerUrl, setServerUrl, fetchSessions, checkServerHealth } from '../../lib/config';
|
|
import { getChatHistory, sendChat, clearChat } from '../../lib/api';
|
|
|
|
const $ = (s) => document.querySelector(s);
|
|
const $$ = (s) => document.querySelectorAll(s);
|
|
|
|
const $serverStatus = $('#server-status');
|
|
const $sessionCount = $('#session-count');
|
|
const $errorCount = $('#error-count');
|
|
const $sessionsList = $('#sessions-list');
|
|
const $btnDashboard = $('#btn-dashboard');
|
|
const $serverUrl = $('#server-url');
|
|
const $btnSaveUrl = $('#btn-save-url');
|
|
const $chatOffline = $('#chat-offline');
|
|
const $chatArea = $('#chat-area');
|
|
const $chatFeed = $('#chat-feed');
|
|
const $chatStreaming = $('#chat-streaming');
|
|
const $chatInput = $('#chat-input');
|
|
const $chatSend = $('#chat-send');
|
|
const $chatStop = $('#chat-stop');
|
|
|
|
let serverOnline = false;
|
|
let messages = [];
|
|
let loading = false;
|
|
let abortController = null;
|
|
let currentStreamingEl = null;
|
|
|
|
function dot(color) {
|
|
return `<span class="dot dot-${color}"></span>`;
|
|
}
|
|
|
|
function renderSessions(sessions) {
|
|
if (sessions.length === 0) {
|
|
$sessionsList.innerHTML = '';
|
|
return;
|
|
}
|
|
$sessionsList.innerHTML = `
|
|
<div class="status-card" style="margin-top:12px">
|
|
<div style="font-size:11px;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">
|
|
Connected tabs
|
|
</div>
|
|
${sessions.map((s) => `
|
|
<div class="status-row">
|
|
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px" title="${s.url}">
|
|
${s.title || s.url || s.id}
|
|
</span>
|
|
<span style="font-size:10px;color:var(--text-secondary);font-family:var(--font-mono)">
|
|
${s.id.slice(0, 8)}
|
|
</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function formatText(text) {
|
|
let html = text
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
html = html
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
|
.replace(/^### (.+)$/gm, '<h4>$1</h4>')
|
|
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
|
|
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
|
|
.replace(/^\s*[-*] (.+)$/gm, '<div class="chat-bullet">• $1</div>')
|
|
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="chat-step"><span class="chat-step-num">$1</span> $2</div>')
|
|
.replace(/\n/g, '<br/>');
|
|
html = html
|
|
.replace(/<br\/>\s*<br\/>/g, '<br/>')
|
|
.replace(/<br\/>\s*(<h[234]|<div class="chat-)/g, '$1')
|
|
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1');
|
|
return html;
|
|
}
|
|
|
|
function renderContent(text) {
|
|
const parts = [];
|
|
const codeBlockRegex = /(```[\s\S]*?```)/g;
|
|
let match;
|
|
let lastIndex = 0;
|
|
while ((match = codeBlockRegex.exec(text)) !== null) {
|
|
if (match.index > lastIndex) {
|
|
parts.push({ type: 'text', content: text.slice(lastIndex, match.index) });
|
|
}
|
|
const full = match[1];
|
|
const firstNewline = full.indexOf('\n');
|
|
const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : '';
|
|
const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3);
|
|
parts.push({ type: 'code', lang, content: code });
|
|
lastIndex = match.index + full.length;
|
|
}
|
|
if (lastIndex < text.length) {
|
|
parts.push({ type: 'text', content: text.slice(lastIndex) });
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function createMessageEl(msg) {
|
|
const el = document.createElement('div');
|
|
el.className = `chat-msg ${msg.role}`;
|
|
|
|
if (msg.role === 'system') {
|
|
el.innerHTML = `<div class="chat-system-dot"></div><div class="chat-system-text">${escapeHtml(msg.content)}</div>`;
|
|
return el;
|
|
}
|
|
|
|
const isUser = msg.role === 'user';
|
|
const avatar = isUser ? '★' : '◆';
|
|
const label = isUser ? 'CDT' : 'GEN';
|
|
|
|
let displayContent = msg.content;
|
|
let parsedToolCalls = null;
|
|
let parsedSegments = null;
|
|
|
|
try {
|
|
const parsed = JSON.parse(msg.content);
|
|
if (parsed && Array.isArray(parsed.segments)) {
|
|
parsedSegments = parsed.segments;
|
|
displayContent = parsed.content || '';
|
|
} else if (parsed && Array.isArray(parsed.tool_calls)) {
|
|
parsedToolCalls = parsed.tool_calls;
|
|
displayContent = parsed.content || '';
|
|
}
|
|
} catch {}
|
|
|
|
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
|
|
|
|
let bodyHtml = '';
|
|
if (parsedSegments) {
|
|
bodyHtml = parsedSegments.map((seg) => {
|
|
if (seg.type === 'text' && seg.content) {
|
|
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
|
|
if (!c) return '';
|
|
return renderContent(c).map((p) => {
|
|
if (p.type === 'code') {
|
|
return `<div class="chat-code-block"><div class="chat-code-header"><span class="chat-code-lang">${p.lang || ''}</span><button class="chat-copy-btn" data-code="${encodeURIComponent(p.content)}">Copy</button></div><pre><code>${escapeHtml(p.content)}</code></pre></div>`;
|
|
}
|
|
return `<span>${formatText(p.content)}</span>`;
|
|
}).join('');
|
|
}
|
|
if (seg.type === 'tool') {
|
|
const name = seg.call?.name || 'tool';
|
|
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', list_files: '📁', search_files: '🔍', web_fetch: '🌐' }[name] || '🔧';
|
|
const done = seg.result;
|
|
const isErr = done && done.is_error;
|
|
const preview = (() => {
|
|
try {
|
|
const args = typeof seg.call.args === 'string' ? JSON.parse(seg.call.args) : seg.call.args;
|
|
return args.command || args.task || args.path || args.url || JSON.stringify(args).slice(0, 60);
|
|
} catch { return ''; }
|
|
})();
|
|
const resultText = done ? (done.content || '').slice(0, 500) : '';
|
|
return `<div class="chat-tool ${done ? 'done' : 'running'} ${isErr ? 'error' : ''}"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span>${done ? `<span class="chat-tool-status ${isErr ? 'err' : 'ok'}">${isErr ? '✗' : '✓'}</span>` : '<span class="chat-dots"><span></span><span></span><span></span></span>'}</div>${preview ? `<div class="chat-tool-args">${escapeHtml(preview)}</div>` : ''}${resultText ? `<pre class="chat-tool-result">${escapeHtml(resultText)}</pre>` : ''}</div>`;
|
|
}
|
|
return '';
|
|
}).join('');
|
|
} else {
|
|
if (cleanContent) {
|
|
bodyHtml = renderContent(cleanContent).map((p) => {
|
|
if (p.type === 'code') {
|
|
return `<div class="chat-code-block"><div class="chat-code-header"><span class="chat-code-lang">${p.lang || ''}</span><button class="chat-copy-btn" data-code="${encodeURIComponent(p.content)}">Copy</button></div><pre><code>${escapeHtml(p.content)}</code></pre></div>`;
|
|
}
|
|
return `<span>${formatText(p.content)}</span>`;
|
|
}).join('');
|
|
}
|
|
if (parsedToolCalls && parsedToolCalls.length > 0) {
|
|
bodyHtml = parsedToolCalls.map((tc) => {
|
|
const name = tc.name || 'tool';
|
|
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', web_fetch: '🌐' }[name] || '🔧';
|
|
return `<div class="chat-tool done"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span><span class="chat-tool-status ok">✓</span></div></div>`;
|
|
}).join('') + bodyHtml;
|
|
}
|
|
}
|
|
|
|
if (!bodyHtml) bodyHtml = '<span class="chat-dots"><span></span><span></span><span></span></span>';
|
|
|
|
el.innerHTML = `
|
|
<div class="chat-avatar ${isUser ? 'user' : 'ai'}">${avatar}</div>
|
|
<div class="chat-body">
|
|
<div class="chat-header"><span class="chat-badge" style="color:${isUser ? '#FFD740' : '#FF9100'};border-color:${isUser ? '#FFD740' : '#FF9100'}">${label}</span></div>
|
|
<div class="chat-content">${bodyHtml}</div>
|
|
</div>
|
|
`;
|
|
return el;
|
|
}
|
|
|
|
function renderMessages() {
|
|
$chatFeed.innerHTML = '';
|
|
messages.forEach((msg) => {
|
|
$chatFeed.appendChild(createMessageEl(msg));
|
|
});
|
|
scrollToBottom();
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
requestAnimationFrame(() => {
|
|
$chatFeed.scrollTop = $chatFeed.scrollHeight;
|
|
});
|
|
}
|
|
|
|
function switchTab(tabName) {
|
|
$$('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tabName));
|
|
$$('.tab-content').forEach((s) => s.classList.toggle('active', s.id === `tab-${tabName}`));
|
|
}
|
|
|
|
function updateChatVisibility() {
|
|
if (serverOnline) {
|
|
$chatOffline.style.display = 'none';
|
|
$chatArea.style.display = 'flex';
|
|
} else {
|
|
$chatOffline.style.display = 'flex';
|
|
$chatArea.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async function loadChatHistory() {
|
|
try {
|
|
const data = await getChatHistory();
|
|
if (data.messages && data.messages.length > 0) {
|
|
messages = data.messages;
|
|
} else {
|
|
messages = [{ id: 'welcome', role: 'system', content: 'Ready. Type a message to start.' }];
|
|
}
|
|
renderMessages();
|
|
} catch {
|
|
messages = [{ id: 'welcome', role: 'system', content: 'Ready. Type a message to start.' }];
|
|
renderMessages();
|
|
}
|
|
}
|
|
|
|
async function handleSend() {
|
|
const text = $chatInput.value.trim();
|
|
if (!text || loading) return;
|
|
|
|
if (text === '/clear') {
|
|
try { await clearChat(); } catch {}
|
|
messages = [{ id: 'clear-' + Date.now(), role: 'system', content: 'Conversation cleared.' }];
|
|
renderMessages();
|
|
$chatInput.value = '';
|
|
return;
|
|
}
|
|
|
|
$chatInput.value = '';
|
|
$chatInput.style.height = 'auto';
|
|
|
|
const userMsg = { id: Date.now().toString(), role: 'user', content: text };
|
|
messages.push(userMsg);
|
|
$chatFeed.appendChild(createMessageEl(userMsg));
|
|
scrollToBottom();
|
|
|
|
loading = true;
|
|
$chatSend.style.display = 'none';
|
|
$chatStop.style.display = 'flex';
|
|
|
|
const controller = new AbortController();
|
|
abortController = controller;
|
|
|
|
let segments = [];
|
|
let thinking = '';
|
|
let textStartIdx = 0;
|
|
let streamText = '';
|
|
|
|
const updateLastText = (text) => {
|
|
if (!text) return;
|
|
const last = segments.length > 0 ? segments[segments.length - 1] : null;
|
|
if (last && last.type === 'text') {
|
|
last.content = text;
|
|
} else {
|
|
segments.push({ type: 'text', content: text });
|
|
}
|
|
};
|
|
|
|
currentStreamingEl = document.createElement('div');
|
|
currentStreamingEl.className = 'chat-msg assistant streaming';
|
|
$chatFeed.appendChild(currentStreamingEl);
|
|
scrollToBottom();
|
|
|
|
try {
|
|
const finalContent = await sendChat(text, true, (partial, event) => {
|
|
if (event && (event.thinking !== undefined || event.thinking_start || event.thinking_end)) {
|
|
if (event.thinking !== undefined) thinking += event.thinking;
|
|
return;
|
|
}
|
|
if (event && event.tool_call) {
|
|
updateLastText(partial.slice(textStartIdx));
|
|
textStartIdx = partial.length;
|
|
segments.push({ type: 'tool', call: event.tool_call, result: null });
|
|
} else if (event && event.tool_result) {
|
|
const segIdx = segments.findIndex((s) => s.type === 'tool' && s.call && s.call.tool_call_id === event.tool_result.tool_call_id);
|
|
if (segIdx >= 0) segments[segIdx].result = event.tool_result;
|
|
} else {
|
|
updateLastText(partial.slice(textStartIdx));
|
|
}
|
|
streamText = partial;
|
|
|
|
const allText = segments.filter((s) => s.type === 'text').map((s) => s.content).join('');
|
|
const toolSegs = segments.filter((s) => s.type === 'tool');
|
|
|
|
let html = '';
|
|
if (thinking) {
|
|
html += `<div class="chat-thinking"><span class="chat-thinking-icon">⏱</span> Thinking…</div>`;
|
|
}
|
|
segments.forEach((seg) => {
|
|
if (seg.type === 'text' && seg.content) {
|
|
const c = seg.content.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '');
|
|
if (c) html += `<div class="chat-content">${formatText(c)}</div>`;
|
|
}
|
|
if (seg.type === 'tool') {
|
|
const name = seg.call?.name || 'tool';
|
|
const icon = { terminal: '⌨', crush_run: '⚡', read_file: '📄', web_fetch: '🌐' }[name] || '🔧';
|
|
const done = seg.result;
|
|
const isErr = done && done.is_error;
|
|
const preview = (() => {
|
|
try {
|
|
const args = typeof seg.call.args === 'string' ? JSON.parse(seg.call.args) : seg.call.args;
|
|
return args.command || args.task || args.path || JSON.stringify(args).slice(0, 60);
|
|
} catch { return ''; }
|
|
})();
|
|
html += `<div class="chat-tool ${done ? 'done' : 'running'} ${isErr ? 'error' : ''}"><div class="chat-tool-header"><span class="chat-tool-icon">${icon}</span><span>${name}</span>${done ? `<span class="chat-tool-status ${isErr ? 'err' : 'ok'}">${isErr ? '✗' : '✓'}</span>` : '<span class="chat-dots"><span></span><span></span><span></span></span>'}</div>${preview ? `<div class="chat-tool-args">${escapeHtml(preview)}</div>` : ''}</div>`;
|
|
}
|
|
});
|
|
|
|
if (!html) {
|
|
html = '<span class="chat-dots"><span></span><span></span><span></span></span>';
|
|
}
|
|
|
|
currentStreamingEl.innerHTML = `
|
|
<div class="chat-avatar ai">◆</div>
|
|
<div class="chat-body">
|
|
<div class="chat-header"><span class="chat-badge" style="color:#FF9100;border-color:#FF9100">GEN</span></div>
|
|
${html}
|
|
<span class="chat-cursor"></span>
|
|
</div>
|
|
`;
|
|
scrollToBottom();
|
|
}, controller.signal);
|
|
|
|
if (currentStreamingEl && currentStreamingEl.parentNode) {
|
|
currentStreamingEl.remove();
|
|
}
|
|
|
|
const allText = segments.filter((s) => s.type === 'text').map((s) => s.content).join('');
|
|
const toolSegs = segments.filter((s) => s.type === 'tool');
|
|
const aiMsg = {
|
|
id: (Date.now() + 1).toString(),
|
|
role: 'assistant',
|
|
content: toolSegs.length > 0 ? JSON.stringify({
|
|
segments: segments.map((s) => s.type === 'text'
|
|
? { type: 'text', content: s.content }
|
|
: { type: 'tool', call: s.call, result: s.result ? { content: s.result.content || '', is_error: s.result.is_error || false, tool_call_id: s.call?.tool_call_id } : null }),
|
|
content: allText,
|
|
}) : (allText || finalContent),
|
|
};
|
|
messages.push(aiMsg);
|
|
$chatFeed.appendChild(createMessageEl(aiMsg));
|
|
scrollToBottom();
|
|
} catch (err) {
|
|
if (currentStreamingEl && currentStreamingEl.parentNode) {
|
|
currentStreamingEl.remove();
|
|
}
|
|
if (err.name !== 'AbortError') {
|
|
const errMsg = { id: (Date.now() + 1).toString(), role: 'system', content: `Error: ${err.message}` };
|
|
messages.push(errMsg);
|
|
$chatFeed.appendChild(createMessageEl(errMsg));
|
|
scrollToBottom();
|
|
}
|
|
} finally {
|
|
loading = false;
|
|
abortController = null;
|
|
currentStreamingEl = null;
|
|
$chatSend.style.display = 'flex';
|
|
$chatStop.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
$$('.tab').forEach((tab) => {
|
|
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
|
|
});
|
|
|
|
$chatInput.addEventListener('input', () => {
|
|
$chatInput.style.height = 'auto';
|
|
$chatInput.style.height = Math.min($chatInput.scrollHeight, 100) + 'px';
|
|
});
|
|
|
|
$chatInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
});
|
|
|
|
$chatSend.addEventListener('click', handleSend);
|
|
$chatStop.addEventListener('click', () => {
|
|
if (abortController) abortController.abort();
|
|
});
|
|
|
|
$chatFeed.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('.chat-copy-btn');
|
|
if (btn) {
|
|
navigator.clipboard.writeText(decodeURIComponent(btn.dataset.code));
|
|
const orig = btn.textContent;
|
|
btn.textContent = 'Copied!';
|
|
setTimeout(() => { btn.textContent = orig; }, 1200);
|
|
}
|
|
});
|
|
|
|
$btnSaveUrl.addEventListener('click', async () => {
|
|
const url = $serverUrl.value.trim().replace(/\/$/, '');
|
|
if (url) {
|
|
await setServerUrl(url);
|
|
refresh();
|
|
}
|
|
});
|
|
|
|
async function refresh() {
|
|
const url = await getServerUrl();
|
|
$serverUrl.value = url;
|
|
$btnDashboard.href = url;
|
|
|
|
try {
|
|
const sessions = await fetchSessions();
|
|
serverOnline = true;
|
|
$serverStatus.innerHTML = `${dot('green')} Online`;
|
|
$sessionCount.textContent = sessions.length;
|
|
renderSessions(sessions);
|
|
} catch {
|
|
serverOnline = false;
|
|
$serverStatus.innerHTML = `${dot('red')} Offline`;
|
|
$sessionCount.textContent = '—';
|
|
$sessionsList.innerHTML = '';
|
|
}
|
|
|
|
updateChatVisibility();
|
|
|
|
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
|
|
if (chrome.runtime.lastError || !state) return;
|
|
$errorCount.textContent = state.errorCount || 0;
|
|
});
|
|
}
|
|
|
|
refresh();
|
|
loadChatHistory();
|
|
setInterval(refresh, 10000);
|