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 fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router
from app.services.scheduler import scheduler_service
from app.services.opencode_client import opencode_client
@@ -35,6 +36,14 @@ app = FastAPI(
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
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 服务正常 |