feat: 添加前端控制台 console.html 及使用指南

This commit is contained in:
2026-03-10 19:37:17 +08:00
parent 341fd0d972
commit a39c1d9fdd
3 changed files with 702 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router from app.api.routes import router
from app.services.scheduler import scheduler_service from app.services.scheduler import scheduler_service
from app.services.opencode_client import opencode_client from app.services.opencode_client import opencode_client
@@ -35,6 +36,14 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router) app.include_router(router)

565
console.html Normal file
View File

@@ -0,0 +1,565 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XCClaw 控制台</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
h1 { font-size: 24px; margin-bottom: 20px; color: #00d9ff; }
.tabs { display: flex; gap: 5px; margin-bottom: 20px; border-bottom: 1px solid #333; }
.tab { padding: 10px 20px; background: transparent; border: none; color: #888; cursor: pointer; font-size: 14px; transition: all 0.2s; }
.tab:hover { color: #fff; }
.tab.active { color: #00d9ff; border-bottom: 2px solid #00d9ff; }
.panel { display: none; }
.panel.active { display: block; }
.card { background: #16213e; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
.card h2 { font-size: 16px; margin-bottom: 15px; color: #00d9ff; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-size: 12px; color: #888; }
input, select, textarea { width: 100%; padding: 10px; background: #0f0f23; border: 1px solid #333; border-radius: 4px; color: #eee; font-size: 14px; }
textarea { resize: vertical; min-height: 80px; }
input:focus, textarea:focus { outline: none; border-color: #00d9ff; }
.btn { padding: 10px 20px; background: #00d9ff; color: #1a1a2e; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500; }
.btn:hover { background: #00b8d4; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-danger { background: #ff4757; }
.btn-danger:hover { background: #ff6b7a; }
.btn-success { background: #2ed573; }
.btn-success:hover { background: #7bed9f; }
.status-badge { display: inline-block; padding: 3px 8px; border-radius: 3px; font-size: 11px; font-weight: 500; }
.status-pending { background: #f39c12; color: #1a1a2e; }
.status-running { background: #3498db; color: #fff; }
.status-completed { background: #2ecc71; color: #1a1a2e; }
.status-failed { background: #e74c3c; color: #fff; }
.status-aborted { background: #95a5a6; color: #1a1a2e; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #333; }
th { color: #888; font-weight: 500; }
tr:hover { background: #1f2b47; }
.log-box { background: #0f0f23; border-radius: 4px; padding: 15px; max-height: 300px; overflow-y: auto; font-family: monospace; font-size: 12px; white-space: pre-wrap; }
.log-entry { margin-bottom: 8px; padding: 8px; background: #16213e; border-radius: 4px; border-left: 3px solid #00d9ff; }
.log-entry.error { border-left-color: #ff4757; }
.log-time { color: #666; font-size: 11px; }
.ws-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
.ws-dot { width: 8px; height: 8px; border-radius: 50%; background: #ff4757; }
.ws-dot.connected { background: #2ed573; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 768px) { .grid { grid-template-columns: 1fr; } }
.flex { display: flex; gap: 10px; align-items: center; }
.flex-between { display: flex; justify-content: space-between; align-items: center; }
</style>
</head>
<body>
<div class="container">
<div class="flex-between">
<h1>XCClaw 控制台</h1>
<div class="ws-status">
<span class="ws-dot" id="wsDot"></span>
<span id="wsStatus">未连接</span>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="tasks">任务管理</button>
<button class="tab" data-tab="schedules">定时任务</button>
<button class="tab" data-tab="history">历史记录</button>
<button class="tab" data-tab="persistent">持久会话</button>
<button class="tab" data-tab="logs">实时日志</button>
</div>
<!-- 任务管理 -->
<div class="panel active" id="panel-tasks">
<div class="grid">
<div class="card">
<h2>创建任务</h2>
<div class="form-group">
<label>任务类型</label>
<select id="taskType">
<option value="ephemeral">即时任务 (Ephemeral)</option>
<option value="persistent">持久任务 (Persistent)</option>
<option value="scheduled">定时任务 (Scheduled)</option>
</select>
</div>
<div class="form-group">
<label>任务描述 (Prompt)</label>
<textarea id="taskPrompt" placeholder="输入任务描述..."></textarea>
</div>
<button class="btn" onclick="createTask()">创建任务</button>
</div>
<div class="card">
<h2>健康检查</h2>
<div id="healthStatus">加载中...</div>
</div>
</div>
<div class="card">
<h2>任务列表</h2>
<button class="btn" onclick="loadTasks()" style="margin-bottom: 15px;">刷新</button>
<table>
<thead>
<tr>
<th>ID</th>
<th>类型</th>
<th>描述</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="tasksTable"></tbody>
</table>
</div>
</div>
<!-- 定时任务 -->
<div class="panel" id="panel-schedules">
<div class="card">
<h2>创建定时任务</h2>
<div class="grid">
<div class="form-group">
<label>任务ID</label>
<input type="text" id="scheduleId" placeholder="唯一标识符">
</div>
<div class="form-group">
<label>任务名称</label>
<input type="text" id="scheduleName" placeholder="任务名称">
</div>
</div>
<div class="form-group">
<label>Cron 表达式</label>
<input type="text" id="scheduleCron" placeholder="0 9 * * *" value="0 9 * * *">
</div>
<div class="form-group">
<label>执行内容</label>
<textarea id="schedulePrompt" placeholder="定时执行的提示词..."></textarea>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="scheduleEnabled" checked> 启用
</label>
</div>
<button class="btn" onclick="createSchedule()">创建定时任务</button>
</div>
<div class="card">
<h2>定时任务列表</h2>
<button class="btn" onclick="loadSchedules()" style="margin-bottom: 15px;">刷新</button>
<table>
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>Cron</th>
<th>提示词</th>
<th>操作</th>
</tr>
</thead>
<tbody id="schedulesTable"></tbody>
</table>
</div>
</div>
<!-- 历史记录 -->
<div class="panel" id="panel-history">
<div class="card">
<div class="flex-between">
<h2>任务历史</h2>
<button class="btn btn-danger" onclick="clearHistory()">清空历史</button>
</div>
<div class="form-group" style="margin-top: 15px;">
<label>显示条数</label>
<input type="number" id="historyLimit" value="20" style="width: 100px;" onchange="loadHistory()">
</div>
<button class="btn" onclick="loadHistory()" style="margin-bottom: 15px;">刷新</button>
<table>
<thead>
<tr>
<th>ID</th>
<th>类型</th>
<th>描述</th>
<th>状态</th>
<th>创建时间</th>
<th>完成时间</th>
</tr>
</thead>
<tbody id="historyTable"></tbody>
</table>
</div>
</div>
<!-- 持久会话 -->
<div class="panel" id="panel-persistent">
<div class="card">
<h2>创建持久会话</h2>
<div class="form-group">
<label>会话名称</label>
<input type="text" id="persistentName" placeholder="会话名称(可选)">
</div>
<button class="btn" onclick="createPersistentSession()">创建</button>
</div>
<div class="card">
<h2>持久会话列表</h2>
<button class="btn" onclick="loadPersistentSessions()" style="margin-bottom: 15px;">刷新</button>
<table>
<thead>
<tr>
<th>ID</th>
<th>会话ID</th>
<th>名称</th>
<th>创建时间</th>
<th>最后使用</th>
<th>操作</th>
</tr>
</thead>
<tbody id="persistentTable"></tbody>
</table>
</div>
<div class="card" id="sendMessagePanel" style="display: none;">
<h2>发送消息</h2>
<div class="form-group">
<label>会话 ID</label>
<input type="text" id="messageSessionId" readonly>
</div>
<div class="form-group">
<label>消息内容</label>
<textarea id="messageText" placeholder="输入消息..."></textarea>
</div>
<div class="flex">
<button class="btn btn-success" onclick="sendMessage(false)">同步发送</button>
<button class="btn" onclick="sendMessage(true)">异步发送</button>
</div>
</div>
</div>
<!-- 实时日志 -->
<div class="panel" id="panel-logs">
<div class="card">
<div class="flex-between">
<h2>实时日志 (WebSocket)</h2>
<button class="btn" onclick="clearLogs()">清空</button>
</div>
<div class="log-box" id="logBox"></div>
</div>
</div>
</div>
<script>
const API_BASE = 'http://127.0.0.1:3005/api/xcclaw';
let ws = null;
let selectedSessionId = null;
// Tab 切换
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('panel-' + tab.dataset.tab).classList.add('active');
});
});
// WebSocket 连接
function connectWS() {
ws = new WebSocket('ws://127.0.0.1:3005/api/xcclaw/ws');
ws.onopen = () => {
document.getElementById('wsDot').classList.add('connected');
document.getElementById('wsStatus').textContent = '已连接';
addLog('WebSocket 连接已建立', 'info');
};
ws.onclose = () => {
document.getElementById('wsDot').classList.remove('connected');
document.getElementById('wsStatus').textContent = '未连接';
addLog('WebSocket 连接已断开', 'error');
setTimeout(connectWS, 3000);
};
ws.onmessage = (e) => {
addLog('收到消息: ' + e.data, 'info');
};
ws.onerror = (e) => {
addLog('WebSocket 错误', 'error');
};
}
function addLog(msg, type = 'info') {
const box = document.getElementById('logBox');
const time = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = 'log-entry' + (type === 'error' ? ' error' : '');
entry.innerHTML = `<span class="log-time">[${time}]</span> ${msg}`;
box.insertBefore(entry, box.firstChild);
if (box.children.length > 100) box.lastChild.remove();
}
function clearLogs() {
document.getElementById('logBox').innerHTML = '';
}
// 健康检查
async function checkHealth() {
try {
const res = await fetch(API_BASE + '/health');
const data = await res.json();
document.getElementById('healthStatus').innerHTML = `
<pre style="background: #0f0f23; padding: 10px; border-radius: 4px; overflow-x: auto;">${JSON.stringify(data, null, 2)}</pre>
`;
} catch (e) {
document.getElementById('healthStatus').innerHTML = '<span style="color: #ff4757;">连接失败: ' + e.message + '</span>';
}
}
// 任务管理
async function createTask() {
const type = document.getElementById('taskType').value;
const prompt = document.getElementById('taskPrompt').value;
if (!prompt) return alert('请输入任务描述');
try {
const res = await fetch(API_BASE + '/task', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({type, prompt})
});
const task = await res.json();
addLog('创建任务: ' + task.id, 'info');
document.getElementById('taskPrompt').value = '';
loadTasks();
} catch (e) {
alert('创建失败: ' + e.message);
}
}
async function loadTasks() {
try {
const res = await fetch(API_BASE + '/task');
const tasks = await res.json();
const tbody = document.getElementById('tasksTable');
tbody.innerHTML = tasks.map(t => `
<tr>
<td>${t.id.substring(0, 8)}...</td>
<td>${t.type}</td>
<td>${t.prompt.substring(0, 30)}${t.prompt.length > 30 ? '...' : ''}</td>
<td><span class="status-badge status-${t.status}">${t.status}</span></td>
<td class="flex">
<button class="btn" style="padding: 5px 10px; font-size: 12px;" onclick="executeTask('${t.id}', false)">执行</button>
<button class="btn btn-success" style="padding: 5px 10px; font-size: 12px;" onclick="executeTask('${t.id}', true)">异步</button>
<button class="btn btn-danger" style="padding: 5px 10px; font-size: 12px;" onclick="abortTask('${t.id}')">中止</button>
</td>
</tr>
`).join('');
} catch (e) {
addLog('加载任务失败: ' + e.message, 'error');
}
}
async function executeTask(taskId, async_) {
try {
const endpoint = async_ ? '/execute_async' : '/execute';
const res = await fetch(API_BASE + '/task/' + taskId + endpoint, {method: 'POST'});
const data = await res.json();
addLog(async_ ? '异步任务已启动: ' + taskId : '执行完成: ' + JSON.stringify(data).substring(0, 100), 'info');
loadTasks();
} catch (e) {
alert('执行失败: ' + e.message);
}
}
async function abortTask(taskId) {
try {
await fetch(API_BASE + '/task/' + taskId + '/abort', {method: 'POST'});
addLog('已中止任务: ' + taskId, 'info');
loadTasks();
} catch (e) {
alert('中止失败: ' + e.message);
}
}
// 定时任务
async function createSchedule() {
const id = document.getElementById('scheduleId').value;
const name = document.getElementById('scheduleName').value;
const cron = document.getElementById('scheduleCron').value;
const prompt = document.getElementById('schedulePrompt').value;
const enabled = document.getElementById('scheduleEnabled').checked;
if (!id || !prompt) return alert('请填写必要字段');
try {
const res = await fetch(API_BASE + '/schedule', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id, name, cron, prompt, enabled})
});
const data = await res.json();
addLog('创建定时任务: ' + id, 'info');
loadSchedules();
} catch (e) {
alert('创建失败: ' + e.message);
}
}
async function loadSchedules() {
try {
const res = await fetch(API_BASE + '/schedule');
const schedules = await res.json();
const tbody = document.getElementById('schedulesTable');
tbody.innerHTML = schedules.map(s => `
<tr>
<td>${s.id}</td>
<td>${s.name || '-'}</td>
<td>${s.cron}</td>
<td>${(s.prompt || '').substring(0, 30)}...</td>
<td>
<button class="btn btn-danger" style="padding: 5px 10px; font-size: 12px;" onclick="deleteSchedule('${s.id}')">删除</button>
</td>
</tr>
`).join('');
} catch (e) {
addLog('加载定时任务失败: ' + e.message, 'error');
}
}
async function deleteSchedule(id) {
if (!confirm('确认删除?')) return;
try {
await fetch(API_BASE + '/schedule/' + id, {method: 'DELETE'});
addLog('删除定时任务: ' + id, 'info');
loadSchedules();
} catch (e) {
alert('删除失败: ' + e.message);
}
}
// 历史记录
async function loadHistory() {
const limit = document.getElementById('historyLimit').value;
try {
const res = await fetch(API_BASE + '/history?limit=' + limit);
const history = await res.json();
const tbody = document.getElementById('historyTable');
tbody.innerHTML = history.map(h => `
<tr>
<td>${h.id.substring(0, 8)}...</td>
<td>${h.type}</td>
<td>${h.prompt.substring(0, 30)}${h.prompt.length > 30 ? '...' : ''}</td>
<td><span class="status-badge status-${h.status}">${h.status}</span></td>
<td>${h.created_at || '-'}</td>
<td>${h.finished_at || '-'}</td>
</tr>
`).join('');
} catch (e) {
addLog('加载历史失败: ' + e.message, 'error');
}
}
async function clearHistory() {
if (!confirm('确认清空所有历史记录?')) return;
try {
await fetch(API_BASE + '/history', {method: 'DELETE'});
addLog('已清空历史记录', 'info');
loadHistory();
} catch (e) {
alert('清空失败: ' + e.message);
}
}
// 持久会话
async function createPersistentSession() {
const name = document.getElementById('persistentName').value;
try {
const res = await fetch(API_BASE + '/persistent' + (name ? '?name=' + encodeURIComponent(name) : ''), {
method: 'POST'
});
const session = await res.json();
addLog('创建持久会话: ' + session.id, 'info');
document.getElementById('persistentName').value = '';
loadPersistentSessions();
} catch (e) {
alert('创建失败: ' + e.message);
}
}
async function loadPersistentSessions() {
try {
const res = await fetch(API_BASE + '/persistent');
const sessions = await res.json();
const tbody = document.getElementById('persistentTable');
tbody.innerHTML = sessions.map(s => `
<tr>
<td>${s.id.substring(0, 8)}...</td>
<td>${s.session_id.substring(0, 8)}...</td>
<td>${s.name || '-'}</td>
<td>${s.created_at || '-'}</td>
<td>${s.last_used_at || '-'}</td>
<td class="flex">
<button class="btn" style="padding: 5px 10px; font-size: 12px;" onclick="selectSession('${s.id}')">发消息</button>
<button class="btn btn-danger" style="padding: 5px 10px; font-size: 12px;" onclick="deleteSession('${s.id}')">删除</button>
</td>
</tr>
`).join('');
} catch (e) {
addLog('加载持久会话失败: ' + e.message, 'error');
}
}
function selectSession(id) {
selectedSessionId = id;
document.getElementById('messageSessionId').value = id;
document.getElementById('sendMessagePanel').style.display = 'block';
}
async function deleteSession(id) {
if (!confirm('确认删除?')) return;
try {
await fetch(API_BASE + '/persistent/' + id, {method: 'DELETE'});
addLog('删除持久会话: ' + id, 'info');
loadPersistentSessions();
} catch (e) {
alert('删除失败: ' + e.message);
}
}
async function sendMessage(async_) {
if (!selectedSessionId) return alert('请选择会话');
const text = document.getElementById('messageText').value;
if (!text) return alert('请输入消息');
try {
const endpoint = async_ ? '/message_async' : '/message';
const res = await fetch(API_BASE + '/persistent/' + selectedSessionId + endpoint + '?text=' + encodeURIComponent(text), {
method: 'POST'
});
const data = await res.json();
addLog(async_ ? '消息已发送(异步)' : '消息已发送: ' + JSON.stringify(data).substring(0, 100), 'info');
document.getElementById('messageText').value = '';
loadPersistentSessions();
} catch (e) {
alert('发送失败: ' + e.message);
}
}
// 初始化
checkHealth();
loadTasks();
loadSchedules();
loadHistory();
loadPersistentSessions();
connectWS();
setInterval(() => {
checkHealth();
loadTasks();
}, 5000);
</script>
</body>
</html>

128
docs/console-guide.md Normal file
View File

@@ -0,0 +1,128 @@
# XCClaw 控制台使用指南
## 访问地址
```
http://localhost:8888/console.html
```
## 界面概览
控制台采用 Tab 切换设计,包含以下模块:
| Tab | 功能 |
|-----|------|
| 任务管理 | 创建、执行、管理即时/持久/定时任务 |
| 定时任务 | 创建和管理 Cron 定时任务 |
| 历史记录 | 查看任务执行历史 |
| 持久会话 | 创建和管理多轮对话会话 |
| 实时日志 | WebSocket 实时推送的任务状态 |
---
## 1. 任务管理
### 创建任务
1. 选择任务类型:
- **Ephemeral (即时任务)**:一次性任务,执行后结束
- **Persistent (持久任务)**:可多轮交互的任务
- **Scheduled (定时任务)**:需要配合定时任务模块使用
2. 输入任务描述 (Prompt)
3. 点击「创建任务」
### 执行任务
创建任务后,在任务列表中可以:
| 按钮 | 功能 |
|------|------|
| 执行 | 同步执行任务,等待完成 |
| 异步 | 异步执行任务,立即返回 |
| 中止 | 终止正在执行的任务 |
### 任务状态
- `pending` - 待执行
- `running` - 执行中
- `completed` - 已完成
- `failed` - 执行失败
- `aborted` - 已中止
---
## 2. 定时任务
### 创建定时任务
| 字段 | 说明 |
|------|------|
| 任务ID | 唯一标识符,如 `daily_task` |
| 任务名称 | 显示名称,如「每日任务」 |
| Cron 表达式 | 执行时间,如 `0 9 * * *` (每天9点) |
| 执行内容 | 定时执行的 Prompt |
| 启用 | 是否启用此定时任务 |
### 常用 Cron 示例
| 表达式 | 含义 |
|--------|------|
| `0 9 * * *` | 每天 9:00 |
| `0 */2 * * *` | 每隔 2 小时 |
| `0 9 * * 1-5` | 工作日 9:00 |
| `*/30 * * * *` | 每隔 30 分钟 |
---
## 3. 历史记录
- 查看所有已完成的任务记录
- 支持限制显示条数
- 可清空历史记录
---
## 4. 持久会话
### 创建会话
输入会话名称(可选),点击「创建」
### 发送消息
1. 在列表中找到目标会话
2. 点击「发消息」按钮
3. 输入消息内容
4. 选择发送方式:
- **同步发送**:等待响应返回
- **异步发送**:立即返回,任务在后台执行
---
## 5. 实时日志
通过 WebSocket 实时显示:
- 任务状态变化
- WebSocket 连接状态
- 操作日志
---
## 快捷操作
- **自动刷新**:健康检查和任务列表每 5 秒自动刷新
- **WebSocket**:页面左上角显示连接状态
---
## 故障排查
| 问题 | 解决方法 |
|------|----------|
| 页面无法访问 | 确认 HTTP 服务运行在 8888 端口 |
| API 请求失败 | 确认 XCClaw 服务运行在 3005 端口 |
| WebSocket 未连接 | 检查网络连接,刷新页面 |
| 任务执行失败 | 查看健康检查确认 OpenCode 服务正常 |