Files
xiaoli-cli/webui.html
T
XiaoLi Contributor 614b081364 feat(webui): 重写 Web UI — WebSocket 后端 + 大厂设计感
- 新增 webui_server.py: WebSocket 后端,桥接小狸 AI 引擎
  - WebSocket 实时通信(对话/工具调用/状态同步)
  - HTTP 静态文件服务
  - 引擎热切换、安全模式切换
  - 延迟初始化,不阻塞启动

- 重写 webui.html: 纯静态前端,连接 WebSocket 后端
  - 设计风格: Linear/Vercel 级别的暗色主题
  - 真实 WebSocket 通信(非前端模拟)
  - 对话/工具双视图
  - 实时状态栏(引擎/安全模式)
  - 代码高亮、打字动画、快速操作
  - 响应式布局

用法:
  python webui_server.py              # 启动 (HTTP:8080, WS:8079)
  python webui_server.py --port 3000  # 自定义端口
  python webui_server.py --no-ai      # 纯 UI 模式
2026-04-28 19:06:12 +08:00

1077 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>小狸 Pro-CLI</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'> </text></svg>">
<style>
/* ═══════════════════════════════════════════
Design System — Inspired by Linear / Vercel
═══════════════════════════════════════════ */
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root {
/* Surface */
--bg-0: #09090b;
--bg-1: #0c0c0e;
--bg-2: #111113;
--bg-3: #18181b;
--bg-4: #1f1f23;
--bg-hover: rgba(255,255,255,.04);
--bg-active: rgba(255,255,255,.06);
/* Border */
--border: rgba(255,255,255,.06);
--border-hover: rgba(255,255,255,.1);
--border-focus: rgba(99,102,241,.5);
/* Text */
--text-0: #fafafa;
--text-1: #a1a1aa;
--text-2: #71717a;
--text-3: #52525b;
/* Accent */
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-soft: rgba(99,102,241,.1);
--accent-softer: rgba(99,102,241,.05);
/* Semantic */
--green: #22c55e;
--green-soft: rgba(34,197,94,.1);
--red: #ef4444;
--red-soft: rgba(239,68,68,.1);
--amber: #f59e0b;
--amber-soft: rgba(245,158,11,.1);
--cyan: #06b6d4;
--cyan-soft: rgba(6,182,212,.1);
/* Radius */
--r-sm: 6px;
--r-md: 8px;
--r-lg: 12px;
--r-xl: 16px;
/* Shadow */
--shadow-sm: 0 1px 2px rgba(0,0,0,.3);
--shadow-md: 0 4px 12px rgba(0,0,0,.4);
--shadow-lg: 0 8px 30px rgba(0,0,0,.5);
/* Transition */
--ease: cubic-bezier(.4,0,.2,1);
--duration: 150ms;
}
html { font-size: 14px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
background: var(--bg-0);
color: var(--text-0);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100vh;
overflow: hidden;
}
/* ═══ Layout ═══ */
.app {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 48px 1fr;
height: 100vh;
}
/* ═══ Topbar ═══ */
.topbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
padding: 0 16px;
background: var(--bg-1);
border-bottom: 1px solid var(--border);
gap: 12px;
z-index: 10;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 13px;
letter-spacing: -.02em;
color: var(--text-0);
padding-right: 12px;
border-right: 1px solid var(--border);
margin-right: 4px;
}
.topbar-brand svg { width: 18px; height: 18px; }
.topbar-tabs {
display: flex;
gap: 2px;
}
.topbar-tab {
padding: 6px 14px;
border-radius: var(--r-sm);
font-size: 12.5px;
font-weight: 500;
color: var(--text-2);
cursor: pointer;
transition: all var(--duration) var(--ease);
border: none;
background: none;
position: relative;
}
.topbar-tab:hover { color: var(--text-1); background: var(--bg-hover); }
.topbar-tab.active {
color: var(--text-0);
background: var(--bg-3);
}
.topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.status-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 100px;
font-size: 11.5px;
font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-1);
cursor: default;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .5; }
}
/* ═══ Sidebar ═══ */
.sidebar {
background: var(--bg-1);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 8px;
}
.sidebar-section {
margin-bottom: 4px;
}
.sidebar-label {
padding: 6px 8px;
font-size: 11px;
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: .05em;
display: flex;
align-items: center;
gap: 6px;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--r-sm);
font-size: 12.5px;
color: var(--text-2);
cursor: pointer;
transition: all var(--duration) var(--ease);
position: relative;
}
.sidebar-item:hover {
color: var(--text-1);
background: var(--bg-hover);
}
.sidebar-item.active {
color: var(--text-0);
background: var(--bg-active);
}
.sidebar-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 16px;
background: var(--accent);
border-radius: 1px;
}
.sidebar-icon {
width: 16px;
text-align: center;
font-size: 12px;
flex-shrink: 0;
opacity: .7;
}
.sidebar-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-badge {
font-size: 10px;
color: var(--text-3);
background: var(--bg-3);
padding: 1px 6px;
border-radius: 100px;
}
.sidebar-sep {
height: 1px;
background: var(--border);
margin: 6px 8px;
}
/* ═══ Main Content ═══ */
.main {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-0);
}
.view { display: none; height: 100%; }
.view.active { display: flex; flex-direction: column; }
/* ═══ Chat View ═══ */
.chat-scroll {
flex: 1;
overflow-y: auto;
padding: 0 24px;
scroll-behavior: smooth;
}
/* Welcome */
.welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
animation: fadeIn .4s var(--ease);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.welcome-icon {
width: 56px;
height: 56px;
border-radius: var(--r-xl);
background: linear-gradient(135deg, var(--accent), #a855f7);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
margin-bottom: 16px;
box-shadow: 0 0 40px rgba(99,102,241,.2);
}
.welcome h1 {
font-size: 20px;
font-weight: 700;
letter-spacing: -.02em;
margin-bottom: 6px;
}
.welcome p {
color: var(--text-2);
font-size: 13px;
max-width: 380px;
margin-bottom: 24px;
}
.quick-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.quick-btn {
padding: 7px 14px;
border-radius: 100px;
font-size: 12px;
font-weight: 500;
color: var(--text-1);
border: 1px solid var(--border);
background: var(--bg-2);
cursor: pointer;
transition: all var(--duration) var(--ease);
display: flex;
align-items: center;
gap: 6px;
}
.quick-btn:hover {
border-color: var(--border-hover);
background: var(--bg-3);
color: var(--text-0);
transform: translateY(-1px);
}
/* Messages */
.message {
padding: 16px 0;
animation: msgIn .3s var(--ease);
}
@keyframes msgIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.message + .message {
border-top: 1px solid var(--border);
}
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.message-avatar {
width: 24px;
height: 24px;
border-radius: var(--r-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
}
.message-avatar.user {
background: var(--cyan-soft);
color: var(--cyan);
}
.message-avatar.ai {
background: var(--accent-soft);
color: var(--accent);
}
.message-sender {
font-size: 12.5px;
font-weight: 600;
color: var(--text-0);
}
.message-time {
font-size: 11px;
color: var(--text-3);
}
.message-body {
padding-left: 32px;
font-size: 13.5px;
line-height: 1.7;
color: var(--text-1);
white-space: pre-wrap;
word-break: break-word;
}
.message-body code {
background: var(--bg-3);
padding: 1px 5px;
border-radius: 4px;
font-size: 12.5px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
color: var(--text-0);
}
.message-body pre {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 12px 16px;
margin: 8px 0;
overflow-x: auto;
font-size: 12.5px;
line-height: 1.6;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.message-body pre code {
background: none;
padding: 0;
font-size: inherit;
}
.tool-card {
margin: 8px 0;
padding: 10px 14px;
background: var(--green-soft);
border: 1px solid rgba(34,197,94,.15);
border-radius: var(--r-md);
font-size: 12.5px;
color: var(--green);
display: flex;
align-items: center;
gap: 8px;
}
.tool-card.error {
background: var(--red-soft);
border-color: rgba(239,68,68,.15);
color: var(--red);
}
/* Typing indicator */
.typing-indicator {
display: flex;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-3);
animation: typingBounce 1.4s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: .2s; }
.typing-indicator span:nth-child(3) { animation-delay: .4s; }
@keyframes typingBounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-5px); }
}
/* Input */
.chat-input-area {
padding: 12px 24px 16px;
border-top: 1px solid var(--border);
background: var(--bg-1);
}
.input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 8px 12px;
transition: border-color var(--duration) var(--ease);
}
.input-wrapper:focus-within {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-softer);
}
.chat-input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-0);
font-size: 13.5px;
font-family: inherit;
line-height: 1.5;
resize: none;
min-height: 20px;
max-height: 120px;
}
.chat-input::placeholder { color: var(--text-3); }
.send-btn {
width: 32px;
height: 32px;
border-radius: var(--r-md);
background: var(--accent);
border: none;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--duration) var(--ease);
flex-shrink: 0;
}
.send-btn:hover { background: var(--accent-hover); transform: scale(1.05); }
.send-btn:active { transform: scale(.95); }
.send-btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
.input-hint {
font-size: 11px;
color: var(--text-3);
padding: 6px 4px 0;
display: flex;
justify-content: space-between;
}
/* ═══ Right Panel (Tools) ═══ */
.tools-panel {
display: none;
}
/* ═══ Responsive ═══ */
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; }
.sidebar { display: none; }
}
/* ═══ Scrollbar ═══ */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
/* ═══ Connection Banner ═══ */
.conn-banner {
padding: 8px 16px;
font-size: 12px;
text-align: center;
font-weight: 500;
display: none;
}
.conn-banner.connecting {
display: block;
background: var(--amber-soft);
color: var(--amber);
border-bottom: 1px solid rgba(245,158,11,.15);
}
.conn-banner.error {
display: block;
background: var(--red-soft);
color: var(--red);
border-bottom: 1px solid rgba(239,68,68,.15);
}
.conn-banner.connected {
display: block;
background: var(--green-soft);
color: var(--green);
border-bottom: 1px solid rgba(34,197,94,.15);
animation: fadeOut 2s forwards 1s;
}
@keyframes fadeOut {
to { opacity: 0; height: 0; padding: 0; border: none; }
}
</style>
</head>
<body>
<div class="app">
<!-- ═══ Topbar ═══ -->
<div class="topbar">
<div class="topbar-brand">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
小狸
</div>
<div class="topbar-tabs">
<button class="topbar-tab active" data-view="chat">对话</button>
<button class="topbar-tab" data-view="tools">工具</button>
</div>
<div class="topbar-right">
<div class="status-pill" id="engine-pill">
<span class="status-dot" id="engine-dot"></span>
<span id="engine-name">连接中...</span>
</div>
<div class="status-pill" id="safety-pill">
<span id="safety-icon"> </span>
<span id="safety-name">普通</span>
</div>
</div>
</div>
<!-- ═══ Sidebar ═══ -->
<div class="sidebar" id="sidebar">
<div class="sidebar-section">
<div class="sidebar-label">项目文件</div>
<div class="sidebar-item active" data-file="ai_cli.py">
<span class="sidebar-icon"> </span>
<span class="sidebar-name">ai_cli.py</span>
<span class="sidebar-badge">入口</span>
</div>
<div class="sidebar-item" data-file="config.json">
<span class="sidebar-icon">{ }</span>
<span class="sidebar-name">config.json</span>
</div>
</div>
<div class="sidebar-sep"></div>
<div class="sidebar-section">
<div class="sidebar-label">核心</div>
<div class="sidebar-item" data-file="cli_core.py">
<span class="sidebar-icon">⚙️</span>
<span class="sidebar-name">cli_core.py</span>
<span class="sidebar-badge">主循环</span>
</div>
<div class="sidebar-item" data-file="safety.py">
<span class="sidebar-icon"> </span>
<span class="sidebar-name">safety.py</span>
</div>
<div class="sidebar-item" data-file="sandbox.py">
<span class="sidebar-icon"> </span>
<span class="sidebar-name">sandbox.py</span>
</div>
<div class="sidebar-item" data-file="memory.py">
<span class="sidebar-icon"> </span>
<span class="sidebar-name">memory.py</span>
</div>
</div>
<div class="sidebar-sep"></div>
<div class="sidebar-section">
<div class="sidebar-label">插件</div>
<div class="sidebar-item" data-file="code_editor.py">
<span class="sidebar-icon">✏️</span>
<span class="sidebar-name">code_editor</span>
<span class="sidebar-badge">24</span>
</div>
<div class="sidebar-item" data-file="git_tools.py">
<span class="sidebar-icon"> </span>
<span class="sidebar-name">git_tools</span>
<span class="sidebar-badge">17</span>
</div>
<div class="sidebar-item" data-file="browser_auto.py">
<span class="sidebar-icon"> </span>
<span class="sidebar-name">browser_auto</span>
<span class="sidebar-badge">28</span>
</div>
<div class="sidebar-item" data-file="sub_agent.py">
<span class="sidebar-icon"> </span>
<span class="sidebar-name">sub_agent</span>
<span class="sidebar-badge">8</span>
</div>
<div class="sidebar-item" data-file="image_generator.py">
<span class="sidebar-icon"> </span>
<span class="sidebar-name">image_generator</span>
<span class="sidebar-badge">7</span>
</div>
<div class="sidebar-item" data-file="memory_plugin.py">
<span class="sidebar-icon"> </span>
<span class="sidebar-name">memory_plugin</span>
<span class="sidebar-badge">11</span>
</div>
<div class="sidebar-item" data-file="scheduler.py">
<span class="sidebar-icon">⏰</span>
<span class="sidebar-name">scheduler</span>
<span class="sidebar-badge">4</span>
</div>
</div>
<div class="sidebar-sep"></div>
<div class="sidebar-section">
<div class="sidebar-label">引擎</div>
<div class="sidebar-item active" data-engine="ollama">
<span class="sidebar-icon"> </span>
<span class="sidebar-name">ollama</span>
<span class="sidebar-badge">本地</span>
</div>
<div class="sidebar-item" data-engine="openai">
<span class="sidebar-icon">☁️</span>
<span class="sidebar-name">openai</span>
</div>
</div>
</div>
<!-- ═══ Main ═══ -->
<div class="main">
<div class="conn-banner" id="conn-banner"></div>
<!-- Chat View -->
<div class="view active" id="view-chat">
<div class="chat-scroll" id="chat-scroll">
<div class="welcome" id="welcome">
<div class="welcome-icon"> </div>
<h1>小狸 Pro-CLI</h1>
<p>智能编程助手 — 代码编辑 · Git · 浏览器自动化 · 多 Agent 协作</p>
<div class="quick-actions">
<button class="quick-btn" data-msg="帮我看看项目结构"> 项目结构</button>
<button class="quick-btn" data-msg="提交代码"> 提交代码</button>
<button class="quick-btn" data-msg="统计代码量"> 代码统计</button>
<button class="quick-btn" data-msg="/help">❓ 帮助</button>
</div>
</div>
</div>
<div class="chat-input-area">
<div class="input-wrapper">
<textarea class="chat-input" id="chat-input" placeholder="输入消息..." rows="1"></textarea>
<button class="send-btn" id="send-btn" title="发送 (Enter)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<div class="input-hint">
<span>Enter 发送 · Shift+Enter 换行</span>
<span id="conn-status">⚪ 未连接</span>
</div>
</div>
</div>
<!-- Tools View -->
<div class="view" id="view-tools">
<div style="padding:24px;overflow-y:auto;flex:1">
<h2 style="font-size:16px;font-weight:700;margin-bottom:16px;color:var(--text-0)">已加载工具</h2>
<div id="tools-list" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:8px"></div>
</div>
</div>
</div>
</div>
<script>
/* ═══════════════════════════════════════════
Client — WebSocket + UI Logic
═══════════════════════════════════════════ */
const WS_URL = `ws://${location.hostname}:8079`;
let ws = null;
let reconnectTimer = null;
let isThinking = false;
// ═══ WebSocket ═══
function connect() {
const banner = document.getElementById('conn-banner');
banner.className = 'conn-banner connecting';
banner.textContent = '⏳ 正在连接服务器...';
try {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
banner.className = 'conn-banner connected';
banner.textContent = '✅ 已连接';
document.getElementById('conn-status').textContent = '🟢 已连接';
clearTimeout(reconnectTimer);
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
handleMessage(msg);
} catch (err) {
console.error('解析消息失败:', err);
}
};
ws.onclose = () => {
document.getElementById('conn-status').textContent = '⚪ 已断开';
banner.className = 'conn-banner error';
banner.textContent = '❌ 连接已断开,正在重连...';
reconnectTimer = setTimeout(connect, 3000);
};
ws.onerror = () => {
banner.className = 'conn-banner error';
banner.textContent = '❌ 无法连接到服务器';
};
} catch (e) {
banner.className = 'conn-banner error';
banner.textContent = '❌ WebSocket 不可用';
}
}
function send(type, data = {}) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
addSystemMessage('未连接到服务器', 'error');
return;
}
ws.send(JSON.stringify({ type, data }));
}
// ═══ Message Handler ═══
function handleMessage(msg) {
switch (msg.type) {
case 'status':
updateStatus(msg.data);
break;
case 'thinking':
showThinking();
break;
case 'reply':
hideThinking();
addMessage('ai', msg.data.message);
break;
case 'tool_executing':
showToolExecuting(msg.data.name, msg.data.args);
break;
case 'tool_result':
hideThinking();
addToolResult(msg.data.name, msg.data.result);
break;
case 'engine_switched':
document.getElementById('engine-name').textContent = msg.data.engine;
addSystemMessage(`引擎已切换: ${msg.data.engine}`);
break;
case 'safety_switched':
document.getElementById('safety-name').textContent = msg.data.mode;
addSystemMessage(`安全模式: ${msg.data.mode}`);
break;
case 'tools':
renderTools(msg.data.tools);
break;
case 'error':
hideThinking();
addSystemMessage(msg.data.message, 'error');
break;
}
}
// ═══ Status ═══
function updateStatus(data) {
document.getElementById('engine-name').textContent = data.engine || '未连接';
document.getElementById('safety-name').textContent = data.safety_mode || '普通';
const dot = document.getElementById('engine-dot');
dot.style.background = data.engine_type === 'local' ? 'var(--green)' :
data.engine_type === 'cloud' ? 'var(--cyan)' : 'var(--text-3)';
// Update sidebar engine items
document.querySelectorAll('[data-engine]').forEach(el => {
el.classList.toggle('active', el.dataset.engine === data.engine);
});
}
// ═══ Chat ═══
function addMessage(role, content) {
const welcome = document.getElementById('welcome');
if (welcome) welcome.remove();
const scroll = document.getElementById('chat-scroll');
const div = document.createElement('div');
div.className = 'message';
const isUser = role === 'user';
const avatar = isUser ? ' ' : ' ';
const name = isUser ? '你' : '小狸';
const time = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
let html = escapeHtml(content);
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
div.innerHTML = `
<div class="message-header">
<div class="message-avatar ${isUser ? 'user' : 'ai'}">${isUser ? ' ' : ' '}</div>
<span class="message-sender">${name}</span>
<span class="message-time">${time}</span>
</div>
<div class="message-body">${html}</div>
`;
scroll.appendChild(div);
scroll.scrollTop = scroll.scrollHeight;
}
function addSystemMessage(text, type = 'info') {
const scroll = document.getElementById('chat-scroll');
const div = document.createElement('div');
div.className = 'message';
const color = type === 'error' ? 'var(--red)' : 'var(--text-2)';
div.innerHTML = `
<div style="padding-left:32px;font-size:12px;color:${color};opacity:.8">${text}</div>
`;
scroll.appendChild(div);
scroll.scrollTop = scroll.scrollHeight;
}
function showThinking() {
if (isThinking) return;
isThinking = true;
const welcome = document.getElementById('welcome');
if (welcome) welcome.remove();
const scroll = document.getElementById('chat-scroll');
const div = document.createElement('div');
div.className = 'message';
div.id = 'thinking-msg';
div.innerHTML = `
<div class="message-header">
<div class="message-avatar ai"> </div>
<span class="message-sender">小狸</span>
</div>
<div class="message-body">
<div class="typing-indicator"><span></span><span></span><span></span></div>
</div>
`;
scroll.appendChild(div);
scroll.scrollTop = scroll.scrollHeight;
document.getElementById('send-btn').disabled = true;
}
function hideThinking() {
isThinking = false;
const el = document.getElementById('thinking-msg');
if (el) el.remove();
document.getElementById('send-btn').disabled = false;
}
function showToolExecuting(name, args) {
addSystemMessage(`⚡ 执行工具: ${name} ${args || ''}`);
}
function addToolResult(name, result) {
const scroll = document.getElementById('chat-scroll');
const div = document.createElement('div');
div.className = 'message';
const isError = result.includes('❌') || result.includes('错误');
div.innerHTML = `
<div class="message-body">
<div class="tool-card ${isError ? 'error' : ''}">
<span>${isError ? '❌' : '✅'}</span>
<span><strong>${name}</strong> ${isError ? '执行失败' : '执行完成'}</span>
</div>
<div style="padding-left:4px;font-size:12.5px;color:var(--text-2);white-space:pre-wrap">${escapeHtml(result).slice(0, 500)}</div>
</div>
`;
scroll.appendChild(div);
scroll.scrollTop = scroll.scrollHeight;
}
// ═══ Tools View ═══
function renderTools(tools) {
const list = document.getElementById('tools-list');
list.innerHTML = tools.map(t => `
<div style="background:var(--bg-2);border:1px solid var(--border);border-radius:var(--r-md);padding:12px 14px;cursor:default;transition:border-color .15s" onmouseenter="this.style.borderColor='var(--border-hover)'" onmouseleave="this.style.borderColor='var(--border)'">
<div style="font-size:13px;font-weight:600;color:var(--text-0);margin-bottom:4px">${t.name}</div>
<div style="font-size:12px;color:var(--text-2);line-height:1.5">${t.description || ''}</div>
</div>
`).join('');
}
// ═══ Input ═══
const input = document.getElementById('chat-input');
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
});
input.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
document.getElementById('send-btn').addEventListener('click', handleSend);
function handleSend() {
const msg = input.value.trim();
if (!msg || isThinking) return;
input.value = '';
input.style.height = 'auto';
addMessage('user', msg);
send('chat', { message: msg });
}
// ═══ Quick Actions ═══
document.querySelectorAll('.quick-btn').forEach(btn => {
btn.addEventListener('click', () => {
input.value = btn.dataset.msg;
handleSend();
});
});
// ═══ Tab Switching ═══
document.querySelectorAll('.topbar-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.topbar-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const view = tab.dataset.view;
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById('view-' + view).classList.add('active');
if (view === 'tools') {
send('tools');
}
});
});
// ═══ Sidebar Engine Click ═══
document.querySelectorAll('[data-engine]').forEach(el => {
el.addEventListener('click', () => {
send('switch_engine', { engine: el.dataset.engine });
});
});
// ═══ Helpers ═══
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ═══ Init ═══
connect();
console.log('%c 小狸 Pro-CLI ', 'background:var(--accent);color:#fff;padding:3px 10px;border-radius:4px;font-weight:600');
</script>
</body>
</html>