Files
Toolbox/public/tools/thought_lab/labs/bot_chess/index.html
T
yangxiangyuan 3ba267b38a feat(server, thought-lab): 优化百炼API配置并新增机器人象棋实验室
- 优化服务端百炼API配置,将硬编码的API地址改为通过环境变量读取,支持自定义部署端点
- 新增机器人象棋思想实验室,包含完整对战逻辑、管理接口与前端页面,配套测试用例
- 优化思想实验室路由注册,新增公开路由绑定能力支持非浏览器客户端调用
2026-08-18 13:23:36 +08:00

289 lines
15 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>机器人象棋 · 裁判室</title>
<style>
:root { --bg:#10141a; --panel:#1a2130; --line:#2a3346; --txt:#dbe4f0; --sub:#8a97ad; --accent:#4f8cff; --red:#e0665e; --black:#7f96b8; --ok:#37c871; --warn:#e0a03c; }
* { box-sizing:border-box; margin:0; padding:0; }
body { background:var(--bg); color:var(--txt); font:14px/1.6 "Microsoft YaHei", sans-serif; padding:16px; max-width:1100px; margin:0 auto; }
h1 { font-size:18px; display:flex; align-items:center; gap:10px; }
.chip { font-size:11px; padding:2px 8px; border-radius:10px; border:1px solid var(--line); color:var(--sub); }
.bar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin:12px 0; background:var(--panel); padding:10px 12px; border-radius:8px; border:1px solid var(--line);}
.bar select { background:#0d1117; color:var(--txt); border:1px solid var(--line); border-radius:6px; padding:5px 8px; }
button { background:#232d42; color:var(--txt); border:1px solid var(--line); border-radius:6px; padding:5px 12px; cursor:pointer; }
button:hover { border-color:var(--accent); }
button:disabled { opacity:.5; cursor:not-allowed; }
button.danger { border-color:var(--red); color:var(--red); }
.grid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
@media (max-width:900px){ .grid{grid-template-columns:1fr;} }
.card { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px; display:flex; flex-direction:column; }
.card h2 { font-size:14px; margin-bottom:8px; color:var(--accent); }
.kv { display:flex; gap:8px; flex-wrap:wrap; font-size:12px; color:var(--sub); margin-bottom:6px;}
.kv b { color:var(--txt); }
pre.board { background:#0d1117; border:1px solid var(--line); border-radius:6px; padding:10px; font-size:12px; overflow:auto; white-space:pre; font-family:Consolas,monospace; flex:1; }
#chat { flex:1; min-height:300px; display:flex; flex-direction:column; gap:8px; overflow-y:auto; border:1px solid var(--line); border-radius:8px; padding:10px; background:#0d1117; }
.msg { max-width:92%; padding:8px 10px; border-radius:8px; white-space:pre-wrap; word-break:break-word; font-size:13px; }
.msg.user { align-self:flex-end; background:#234; border:1px solid var(--accent); }
.msg.ref { align-self:flex-start; background:#232d42; border:1px solid var(--line); }
.msg.tool { align-self:center; font-size:11px; color:var(--sub); background:transparent; border:1px dashed var(--line); }
.tool { background:#232d42; border:1px solid var(--line); border-radius:6px; color:var(--sub); font-size:11px; text-align:center; padding:2px 8px; align-self:center; }
.input-row { display:flex; gap:8px; margin-top:10px; }
.input-row input { flex:1; background:#0d1117; color:var(--txt); border:1px solid var(--line); border-radius:6px; padding:8px 10px; }
.modal { position:fixed; inset:0; background:rgba(0,0,0,.55); display:none; align-items:center; justify-content:center; z-index:50; }
.modal.show { display:flex; }
.modal-box { background:var(--panel); border:1px solid var(--line); border-radius:10px; max-width:560px; width:92%; max-height:80vh; overflow:auto; padding:16px; }
.modal-box h3 { font-size:15px; margin-bottom:8px; color:var(--accent); }
.modal-box pre { background:#0d1117; border:1px solid var(--line); border-radius:6px; padding:8px; font-size:12px; overflow:auto; white-space:pre-wrap; word-break:break-all; }
.modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; }
.toast { position:fixed; left:50%; top:20px; transform:translateX(-50%); padding:8px 16px; border-radius:8px; font-size:13px; color:#fff; z-index:99; display:none; }
.toast.info { background:#2f6fd6; } .toast.ok { background:var(--ok); } .toast.err { background:var(--red); }
.rec { font-size:12px; color:var(--sub); }
</style>
</head>
<body>
<h1>♟️ 机器人象棋 · 裁判室 <span class="chip" id="modeChip">模式…</span></h1>
<div class="bar">
<span class="rec">对局:</span>
<select id="matchSel"><option value="">(选择比赛)</option></select>
<button id="btnRefresh" onclick="loadDetail()">刷新</button>
<a id="lnkLog" href="#" target="_blank"><button>导出MD日志</button></a>
<button id="btnAbort" class="danger" onclick="confirmAbort()">中止比赛</button>
<span id="scoreBox" class="rec"></span>
</div>
<div class="grid">
<div class="card">
<h2>当前局面</h2>
<div class="kv" id="kvBox">(无)</div>
<pre class="board" id="boardBox">—</pre>
</div>
<div class="card">
<h2>对话(只跟裁判说)</h2>
<div id="chat"></div>
<div class="input-row">
<input id="msgInput" placeholder="例:开始检测两个bot / 汇报一下局面 / 开始">
<button id="btnSend" onclick="sendMsg()">发送</button>
</div>
</div>
</div>
<div class="card" style="margin-top:12px;">
<h2>对局流水 <span class="chip" id="runLogStatus">等待选局</span></h2>
<pre class="board" id="runLog" style="max-height:260px;">—</pre>
</div>
<div class="modal" id="dlg"><div class="modal-box">
<h3 id="dlgTitle">交互详情</h3>
<pre id="dlgBody">-</pre>
<div class="modal-actions" id="dlgActions">
<button onclick="copyDlg()">复制</button>
<button onclick="closeDlg()">关闭</button>
</div>
</div></div>
<script>
const API = '/api/thought_lab/bot_chess'
let uiMode = 'debug'
let matches = []
let currentId = 0
// ---------- 双模式工具:Toast / 交互详情弹窗 / 危险确认 ----------
function toast(text, type) {
let el = document.querySelector('.toast')
if (!el) { el = document.createElement('div'); el.className = 'toast'; document.body.appendChild(el) }
el.textContent = text
el.className = 'toast ' + type
el.style.display = 'block'
clearTimeout(el._t)
el._t = setTimeout(() => { el.style.display = 'none' }, 2000)
}
const dlgTimer = { t: null }
function openDlg(title, body, { stay = false, danger = false } = {}) {
document.getElementById('dlgTitle').textContent = title
document.getElementById('dlgBody').textContent = body
document.getElementById('dlgActions').innerHTML = danger
? '<button class="danger" onclick="onDanger()">确认执行</button><button onclick="closeDlg()">取消</button>'
: '<button onclick="copyDlg()">复制</button><button onclick="closeDlg()">关闭</button>'
document.getElementById('dlg').classList.add('show')
clearTimeout(dlgTimer.t)
if (!stay) dlgTimer.t = setTimeout(closeDlg, uiMode === 'debug' ? 3000 : 2000)
}
function closeDlg() { document.getElementById('dlg').classList.remove('show'); clearTimeout(dlgTimer.t) }
function copyDlg() { const t = document.getElementById('dlgBody').textContent; (navigator.clipboard ? navigator.clipboard.writeText(t) : Promise.reject()).then(() => toast('已复制', 'ok')).catch(() => { prompt('复制内容:', t) }) }
let onDanger = () => {}
function confirmDanger(title, body, fn) { onDanger = fn; openDlg(title, body, { stay: true, danger: true }) }
// 异步交互包装:debug→详情报错弹窗,normal→成功 Toast、失败强弹窗
async function tracked(name, fn, { silent = false } = {}) {
const t0 = Date.now()
try {
const data = await fn()
const line = `触发:${name}\n时间:${new Date().toLocaleTimeString('zh-CN')}\n接口:${data._url || ''}\n请求:${data._req || ''}\n返回:${JSON.stringify(data._body || data)?.slice(0, 800)}\n耗时:${Date.now() - t0}ms`
if (uiMode === 'debug') { localDlg = line; openDlg(`交互详情 · ${name}`, line, { stay: false }) } else if (!silent) { toast('操作成功', 'ok') }
return data
} catch (e) {
const line = `触发:${name}\n时间:${new Date().toLocaleTimeString('zh-CN')}\n错误:${e.message || e}\n耗时:${Date.now() - t0}ms`
openDlg(`出错 · ${name}`, line, { stay: true })
return null
}
}
let localDlg = ''
// fetch 封装:记录 url / 请求体重用
const j = async (url, opts = {}) => {
const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {})
const resp = await fetch(url, Object.assign({ headers }, opts))
let body = null
try { body = await resp.json() } catch (e) { body = null }
if (!resp.ok) throw new Error((body && body.error) || body && body.code || ('HTTP ' + resp.status))
return body
}
// ---------- 主流程 ----------
async function init() {
const meta = await j(API + '/meta').catch(() => ({ ui_mode: 'debug' }))
uiMode = meta.ui_mode || 'debug'
document.getElementById('modeChip').textContent = '模式 ' + (uiMode === 'debug' ? '研发调试' : '日常使用')
loadMatches()
setTimeout(loadDetail, 200)
// 页面可见时自动刷新;对局流水每 5s 增量拉取
setInterval(() => { if (document.visibilityState === 'visible') loadMatches(true) }, 20000)
setInterval(pollRunLog, 5000)
}
async function loadMatches(silent) {
const data = await j(API + '/matches').catch(() => null)
if (!data) return
matches = data.matches || []
const sel = document.getElementById('matchSel')
const prev = sel.value
sel.innerHTML = '<option value="">(选择比赛)</option>' + matches.map(m =>
`<option value="${m.id}">#${m.id} ${m.title}|${m.red_bot} vs ${m.black_bot}(${m.score_red}:${m.score_black},${m.status})</option>`
).join('')
if (prev) sel.value = prev
if (!prev && matches.length) sel.value = String(matches[0].id)
currentId = Number(sel.value || 0)
if (!silent) loadDetail()
}
async function loadDetail() {
currentId = Number(document.getElementById('matchSel').value || 0)
loadRunLogAll()
if (!currentId) { document.getElementById('boardBox').textContent = '请先创建/选择比赛'; document.getElementById('kvBox').textContent = '(无)'; return }
const s = await j(API + '/matches/' + currentId).catch(() => null)
if (!s) return
const m = s.match, g = s.game
const kv = []
kv.push(`<b>比分</b> ${m.red_bot} ${m.score_red}:${m.score_black} ${m.black_bot}`)
kv.push(`<b>赛制</b> ${m.best_of}局`)
kv.push(`<b>状态</b> ${m.status}`)
kv.push(`<b>模型</b> 红 ${m.red_model} / 黑 ${m.black_model}`)
if (m.winner) kv.push(`<b>结果</b> ${m.winner === 'red' ? '红胜' : m.winner === 'black' ? '黑胜' : '平局'}`)
if (g) {
kv.push(`<b>第${g.game_no}局</b> ${g.status}`)
kv.push(`<b>轮到</b> ${g.turn === 'b' ? '黑方' : '红方'}`)
kv.push(`<b>手数</b> ${g.move_count}`)
kv.push(`<b>犯规</b> 红${g.red_fouls}/黑${g.black_fouls}`)
}
document.getElementById('kvBox').innerHTML = kv.join(' ')
document.getElementById('boardBox').textContent = s.board ? s.board.ascii + '\n\nFEN: ' + s.board.fen + (g ? '' : '') : '(当前无局面)'
document.getElementById('lnkLog').href = API + '/matches/' + currentId + '/log.md'
}
document.getElementById('matchSel').addEventListener('change', loadDetail)
// 创建比赛
async function createMatch() {
await tracked('创建比赛', async () => {
const title = prompt('比赛名称', '模型对战')
if (title === null) throw new Error('已取消')
const rb = prompt('红方 bot 名称', '红方Bot')
const bb = prompt('黑方 bot 名称', '黑方Bot')
const bo = prompt('局数(三局两胜=3)', '3')
const d = await j(API + '/matches', { method: 'POST', body: JSON.stringify({ title, red_bot: rb, black_bot: bb, best_of: Number(bo) || 3 }) })
currentId = d.match_id
if (uiMode === 'debug') toast(`已创建 #${d.match_id},red_token 已返回(调试可见)`, 'ok')
loadMatches()
return { _url: 'POST /matches', _req: JSON.stringify({ title, red_bot: rb, black_bot: bb, best_of: bo }), _body: d }
})
}
// ---------- 对局流水(滚动日志)----------
let runLogLastId = 0
let runLogAuto = true // 是否自动跟随滚动到最新
function runLogLine(ev) {
const t = String(ev.created_at || '').slice(11, 19)
const tag = { move: '走子', foul: '犯规', game_started: '开赛', game_finished: '终局', match_created: '建赛', match_finished: '结算', start: '开始', abort: '中止', note: '备注' }[ev.kind] || ev.kind
const side = ev.side === 'red' ? '红' : ev.side === 'black' ? '黑' : ''
return `[${t}] [${tag}]${side ? '(' + side + ')' : ''} ${ev.content}`
}
function appendRunLog(lines) {
const box = document.getElementById('runLog')
if (!lines || !lines.length) return
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 20
let text = box.textContent === '—' ? '' : box.textContent
const arr = text ? text.split('\n') : []
for (const l of lines) arr.push(l)
box.textContent = arr.slice(-500).join('\n')
if (runLogAuto || atBottom) box.scrollTop = box.scrollHeight
document.getElementById('runLogStatus').textContent = `共 ${arr.length} 行`
}
async function loadRunLogAll() {
runLogLastId = 0
document.getElementById('runLog').textContent = '—'
if (!currentId) { document.getElementById('runLogStatus').textContent = '等待选局'; return }
const d = await j(API + `/matches/${currentId}/events?all=1`).catch(() => null)
if (!d || !d.events) return
appendRunLog(d.events.map(runLogLine))
runLogLastId = d.events.length ? d.events[d.events.length - 1].id : 0
}
async function pollRunLog() {
if (!currentId) return
const d = await j(API + `/matches/${currentId}/events?after=${runLogLastId}`).catch(() => null)
if (!d || !d.events || !d.events.length) return
appendRunLog(d.events.map(runLogLine))
runLogLastId = d.events[d.events.length - 1].id
// 比赛收关后静默停(日志已全量保留在页面)
const sEl = document.getElementById('runLogStatus')
if (sEl && d.events.some(e => e.kind === 'match_finished' || e.kind === 'abort')) sEl.textContent += ' · 已结束'
}
function appendMsg(role, text) {
const c = document.getElementById('chat')
const d = document.createElement('div')
d.className = 'msg ' + role
d.textContent = text
c.appendChild(d)
c.scrollTop = c.scrollHeight
return d
}
async function sendMsg() {
const input = document.getElementById('msgInput')
const text = input.value.trim()
if (!text) return
const btn = document.getElementById('btnSend')
btn.disabled = true; btn.textContent = '发送中…'
appendMsg('user', text)
input.value = ''
try {
const d = await tracked('裁判对话', () => j(API + '/chat', { method: 'POST', body: JSON.stringify({ message: text }) }), { silent: true })
if (!d) { appendMsg('tool', '(裁判接口出错,见弹窗)') }
else {
for (const ev of (d.tool_events || [])) appendMsg('tool', `[工具] ${ev.name} → ${String(ev.summary).slice(0, 120)}`)
appendMsg('ref', d.reply || '…')
if (d.match_id) { setTimeout(loadMatches, 500) }
}
} catch (e) { appendMsg('tool', '(发送失败:' + e.message + ')') }
btn.disabled = false; btn.textContent = '发送'
}
document.getElementById('msgInput').addEventListener('keydown', e => { if (e.key === 'Enter') sendMsg() })
// ---------- 危险操作(双模式均用确认弹窗)----------
function confirmAbort() {
if (!currentId) return toast('请先选择比赛', 'info')
confirmDanger('中止比赛', `确定中止 #${currentId} 比赛吗?(不可恢复,本场判中止)`, async () => {
await tracked('中止比赛', () => j(API + '/matches/' + currentId + '/abort', { method: 'POST', body: JSON.stringify({}) }))
closeDlg(); loadMatches()
})
}
init()
</script>
</body>
</html>