feat(extension): Chrome/Edge only + side panel chat tabs (v0.9.0)
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>
This commit is contained in:
Augustin
2026-04-27 18:48:04 +02:00
parent 31b1de1b0d
commit f4af63afec
11 changed files with 955 additions and 106 deletions

View File

@@ -40,14 +40,6 @@ jobs:
restore-keys: |
${{ runner.os }}-node-web-
- name: Cache Node modules (extension)
uses: actions/cache@v4
with:
path: extension/node_modules
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-ext-
- name: Download Go dependencies
run: go mod download
@@ -62,7 +54,6 @@ jobs:
cd extension
npm ci
npx wxt zip
npx wxt zip --browser firefox
mkdir -p ../dist
mv .output/muyue-extension-*.zip ../dist/

View File

@@ -40,14 +40,6 @@ jobs:
restore-keys: |
${{ runner.os }}-node-web-
- name: Cache Node modules (extension)
uses: actions/cache@v4
with:
path: extension/node_modules
key: ${{ runner.os }}-node-ext-${{ hashFiles('extension/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-ext-
- name: Download dependencies
run: go mod download
@@ -62,7 +54,6 @@ jobs:
cd extension
npm ci
npx wxt zip
npx wxt zip --browser firefox
mkdir -p ../dist
mv .output/muyue-extension-*.zip ../dist/

View File

@@ -9,7 +9,7 @@ WEB_DIR = web
EXT_DIR = extension
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop ext ext-chrome ext-firefox ext-zip
.PHONY: build install clean test test-short run scan fmt lint build-all deps vet frontend dev-desktop ext ext-zip
frontend:
cd $(WEB_DIR) && $(NPM) ci && $(NPM) run build
@@ -66,16 +66,10 @@ build-all: frontend
GOOS=windows GOARCH=arm64 $(GO) build -o dist/$(BINARY)-windows-arm64.exe ./cmd/muyue/
ext:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build && $(NPM) run build:firefox
ext-chrome:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build
ext-firefox:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run build:firefox
ext-zip:
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run zip && $(NPM) run zip:firefox
cd $(EXT_DIR) && $(NPM) ci && $(NPM) run zip
deps:
$(GO) mod tidy

View File

@@ -1,14 +1,12 @@
{
"name": "muyue-extension",
"version": "0.8.0",
"version": "0.9.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wxt",
"build": "wxt build",
"build:firefox": "wxt build --browser firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip --browser firefox"
"zip": "wxt zip"
},
"dependencies": {
"wxt": "^0.20"

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=320" />
</head>
<body>
<div class="panel">
<div class="panel" style="width:320px">
<header>
<img src="/icon/32.png" alt="Muyue" />
<h1>Muyue</h1>
@@ -33,7 +33,7 @@
Open Dashboard
</a>
<button id="btn-sidepanel" class="btn">
Open Side Panel
Open Chat Panel
</button>
</div>
@@ -46,7 +46,7 @@
</div>
<div class="footer">
<span>Muyue</span> browser extension v0.1.0
<span>Muyue</span> extension v0.9.0
</div>
</div>

View File

@@ -2,15 +2,21 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=100%" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div class="panel">
<header>
<img src="/icon/32.png" alt="Muyue" />
<h1>Muyue Side Panel</h1>
<h1>Muyue</h1>
</header>
<nav class="tabs">
<button class="tab active" data-tab="config">Configuration</button>
<button class="tab" data-tab="chat">Chat</button>
</nav>
<section id="tab-config" class="tab-content active">
<div class="status-card">
<div class="status-row">
<span class="status-label">Server</span>
@@ -43,9 +49,36 @@
<button id="btn-save-url">Save</button>
</div>
</div>
</section>
<section id="tab-chat" class="tab-content">
<div id="chat-offline" class="chat-offline">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
</svg>
<span>Server offline</span>
</div>
<div id="chat-area" class="chat-area" style="display:none">
<div id="chat-feed" class="chat-feed"></div>
<div id="chat-streaming" class="chat-streaming" style="display:none"></div>
<div class="chat-input-row">
<textarea id="chat-input" placeholder="Send a message…" rows="1"></textarea>
<button id="chat-send" class="chat-send-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
<button id="chat-stop" class="chat-stop-btn" style="display:none">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</button>
</div>
</div>
</section>
<div class="footer">
<span>Muyue</span> browser extension v0.1.0
<span>Muyue</span> extension v0.9.0
</div>
</div>

View File

@@ -1,13 +1,30 @@
import '../../styles/panel.css';
import { getServerUrl, setServerUrl, fetchSessions } from '../../lib/config';
import { getServerUrl, setServerUrl, fetchSessions, checkServerHealth } from '../../lib/config';
import { getChatHistory, sendChat, clearChat } from '../../lib/api';
const $serverStatus = document.getElementById('server-status');
const $sessionCount = document.getElementById('session-count');
const $errorCount = document.getElementById('error-count');
const $sessionsList = document.getElementById('sessions-list');
const $btnDashboard = document.getElementById('btn-dashboard');
const $serverUrl = document.getElementById('server-url');
const $btnSaveUrl = document.getElementById('btn-save-url');
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>`;
@@ -18,7 +35,6 @@ function renderSessions(sessions) {
$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">
@@ -38,28 +54,360 @@ function renderSessions(sessions) {
`;
}
async function refresh() {
const url = await getServerUrl();
$serverUrl.value = url;
$btnDashboard.href = url;
function formatText(text) {
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 sessions = await fetchSessions();
$serverStatus.innerHTML = `${dot('green')} Online`;
$sessionCount.textContent = sessions.length;
renderSessions(sessions);
} catch {
$serverStatus.innerHTML = `${dot('red')} Offline`;
$sessionCount.textContent = '';
$sessionsList.innerHTML = '';
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;
}
}
chrome.runtime.sendMessage({ type: 'get_state' }, (state) => {
if (chrome.runtime.lastError || !state) return;
$errorCount.textContent = state.errorCount || 0;
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) {
@@ -68,5 +416,32 @@ $btnSaveUrl.addEventListener('click', async () => {
}
});
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();
setInterval(refresh, 5000);
loadChatHistory();
setInterval(refresh, 10000);

77
extension/src/lib/api.js Normal file
View File

@@ -0,0 +1,77 @@
import { getServerUrl } from './config';
async function request(path, options = {}) {
const base = await getServerUrl();
const res = await fetch(`${base}/api${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
return res.json();
}
export async function getChatHistory() {
return request('/chat/history');
}
export async function clearChat() {
return request('/chat/clear', { method: 'POST' });
}
export async function summarizeChat() {
return request('/chat/summarize', { method: 'POST' });
}
export async function sendChat(message, stream = true, onChunk, signal) {
const base = await getServerUrl();
if (!stream) {
return request('/chat', {
method: 'POST',
body: JSON.stringify({ message, stream: false }),
});
}
return new Promise((resolve, reject) => {
fetch(`${base}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, stream: true }),
signal,
}).then(async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
reject(new Error(err.error || res.statusText));
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
for (const line of text.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.error) { reject(new Error(data.error)); return; }
if (data.done) { resolve(full); return; }
if (data.content) {
full += data.content;
if (onChunk) onChunk(full, data);
} else if (data.thinking !== undefined || data.thinking_end) {
if (onChunk) onChunk(full, data);
} else if (data.tool_call || data.tool_result) {
if (onChunk) onChunk(full, data);
}
} catch {}
}
}
resolve(full);
}).catch(reject);
});
}

View File

@@ -30,17 +30,19 @@ body {
}
.panel {
width: 320px;
padding: 16px;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
header {
.panel > header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding-bottom: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
header img {
@@ -54,6 +56,47 @@ header h1 {
letter-spacing: -0.3px;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
padding: 0 8px;
}
.tab {
flex: 1;
padding: 10px 8px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-content {
display: none;
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.tab-content.active {
display: flex;
flex-direction: column;
}
.status-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
@@ -189,12 +232,13 @@ header h1 {
}
.footer {
margin-top: 12px;
padding-top: 10px;
margin-top: auto;
padding: 10px 16px;
border-top: 1px solid var(--border);
text-align: center;
color: var(--text-secondary);
font-size: 10px;
flex-shrink: 0;
}
.footer span {
@@ -209,3 +253,351 @@ header h1 {
.loading {
animation: pulse 1.5s ease-in-out infinite;
}
.chat-offline {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-secondary);
font-size: 13px;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
gap: 0;
}
.chat-feed {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 8px;
}
.chat-msg {
display: flex;
gap: 8px;
max-width: 100%;
}
.chat-msg.system {
align-items: center;
gap: 6px;
padding: 6px 0;
}
.chat-system-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-secondary);
flex-shrink: 0;
}
.chat-system-text {
color: var(--text-secondary);
font-size: 12px;
font-style: italic;
}
.chat-avatar {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.chat-avatar.user {
background: rgba(255, 215, 64, 0.15);
color: #FFD740;
}
.chat-avatar.ai {
background: rgba(255, 145, 0, 0.15);
color: #FF9100;
}
.chat-body {
flex: 1;
min-width: 0;
overflow-wrap: break-word;
}
.chat-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.chat-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
border: 1px solid;
line-height: 1.3;
}
.chat-content {
font-size: 13px;
line-height: 1.5;
}
.chat-content h2 { font-size: 14px; margin: 8px 0 4px; }
.chat-content h3 { font-size: 13px; margin: 6px 0 3px; }
.chat-content h4 { font-size: 12px; margin: 4px 0 2px; }
.chat-content strong { color: #fff; }
.chat-bullet, .chat-step {
padding-left: 4px;
margin: 2px 0;
}
.chat-step-num {
display: inline-block;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent-dim);
text-align: center;
line-height: 18px;
font-size: 10px;
font-weight: 600;
margin-right: 4px;
}
.inline-code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 5px;
border-radius: 3px;
font-family: var(--font-mono);
font-size: 12px;
}
.chat-code-block {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
margin: 6px 0;
overflow: hidden;
}
.chat-code-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--border);
}
.chat-code-lang {
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
}
.chat-copy-btn {
background: none;
border: none;
color: var(--accent);
font-size: 10px;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
}
.chat-copy-btn:hover {
background: var(--accent-dim);
}
.chat-code-block pre {
padding: 8px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
}
.chat-code-block code {
font-family: inherit;
}
.chat-tool {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
margin: 6px 0;
font-size: 12px;
}
.chat-tool.error {
border-color: var(--red);
}
.chat-tool-header {
display: flex;
align-items: center;
gap: 6px;
}
.chat-tool-icon {
font-size: 14px;
}
.chat-tool-status {
margin-left: auto;
font-weight: 700;
}
.chat-tool-status.ok { color: var(--green); }
.chat-tool-status.err { color: var(--red); }
.chat-tool-args {
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-tool-result {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
max-height: 120px;
overflow-y: auto;
}
.chat-thinking {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
margin-bottom: 6px;
font-size: 11px;
color: var(--text-secondary);
}
.chat-thinking-icon {
font-size: 13px;
}
@keyframes chatDotPulse {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.chat-dots {
display: inline-flex;
gap: 4px;
padding: 4px 0;
}
.chat-dots span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-secondary);
animation: chatDotPulse 1.2s ease-in-out infinite;
}
.chat-dots span:nth-child(2) { animation-delay: 0.2s; }
.chat-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.chat-cursor {
display: inline-block;
width: 2px;
height: 14px;
background: var(--accent);
margin-left: 2px;
vertical-align: text-bottom;
animation: cursorBlink 0.8s ease infinite;
}
.chat-input-row {
display: flex;
align-items: flex-end;
gap: 6px;
padding: 8px 0 0;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.chat-input-row textarea {
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 13px;
resize: none;
outline: none;
max-height: 100px;
line-height: 1.4;
}
.chat-input-row textarea:focus {
border-color: var(--accent);
}
.chat-send-btn, .chat-stop-btn {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
}
.chat-send-btn:hover, .chat-stop-btn:hover {
box-shadow: 0 0 12px var(--accent-glow);
}
.chat-stop-btn {
background: var(--bg-secondary);
border-color: var(--border);
color: var(--text-primary);
}
.chat-msg.user .chat-content {
background: var(--bg-tertiary);
padding: 8px 10px;
border-radius: 0 8px 8px 8px;
}
.chat-msg.assistant .chat-content {
padding: 2px 0;
}

View File

@@ -2,19 +2,17 @@ import { defineConfig } from 'wxt';
export default defineConfig({
srcDir: 'src',
suppressWarnings: {
firefoxDataCollection: true,
},
manifest: ({ browser }) => ({
manifest: {
name: 'Muyue',
description: 'AI-powered browser testing & automation — connected to your Muyue desktop app',
permissions: [
'storage',
'activeTab',
'tabs',
...(browser === 'chrome' ? ['sidePanel'] : []),
'sidePanel',
'scripting',
'notifications',
'alarms',
],
host_permissions: ['http://127.0.0.1:*/*', 'http://localhost:*/*'],
action: {
@@ -26,5 +24,5 @@ export default defineConfig({
side_panel: {
default_path: 'sidepanel.html',
},
}),
},
});

View File

@@ -7,7 +7,7 @@ import (
const (
Name = "muyue"
Version = "0.8.0"
Version = "0.9.0"
Author = "La Légion de Muyue"
)