feat(server, thought-lab): 优化百炼API配置并新增机器人象棋实验室

- 优化服务端百炼API配置,将硬编码的API地址改为通过环境变量读取,支持自定义部署端点
- 新增机器人象棋思想实验室,包含完整对战逻辑、管理接口与前端页面,配套测试用例
- 优化思想实验室路由注册,新增公开路由绑定能力支持非浏览器客户端调用
This commit is contained in:
yangxiangyuan
2026-08-18 13:23:36 +08:00
parent e4f84caa23
commit 3ba267b38a
20 changed files with 2639 additions and 11 deletions
@@ -0,0 +1,289 @@
<!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>