Files
ai-town/web/index.html
empty 8b90d55f02 feat: AI Town MVP - 完整三层架构实现
- Python FastAPI 引擎:世界状态模拟、全局能量条、世界事件系统
- Node.js WebSocket 服务器:实时通信、事件队列批处理
- 前端仪表盘:世界状态可视化、行动日志、事件展示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 10:29:35 +08:00

250 lines
10 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>AI Town</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e4e4e7;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
header { text-align: center; margin-bottom: 20px; }
header h1 {
font-size: 28px;
background: linear-gradient(90deg, #60a5fa, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.status-bar { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 8px; font-size: 13px; color: #71717a; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }
.status-dot.connected { background: #22c55e; }
/* 输入区域 */
.input-section { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
.input-row { display: flex; gap: 8px; margin-bottom: 10px; }
.input-row input { flex: 1; padding: 10px 14px; border: 1px solid #3f3f46; border-radius: 8px; background: #27272a; color: #e4e4e7; font-size: 14px; outline: none; }
.input-row input:focus { border-color: #60a5fa; }
button { padding: 10px 18px; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
.quick-btns { display: flex; gap: 8px; }
.btn-quick { background: #3f3f46; color: #a1a1aa; }
.btn-quick:hover { background: #52525b; color: #e4e4e7; }
/* 能量条 - 极简 */
#global-meter { width: 100%; height: 16px; background: #27272a; border-radius: 8px; margin-bottom: 16px; overflow: hidden; }
#meter-bar { height: 100%; width: 0%; background: linear-gradient(90deg, #00ffcc, #ff0066); transition: width 0.3s ease; }
/* 世界事件提示 */
#world-event { background: #422006; border: 1px solid #f59e0b; border-radius: 8px; padding: 12px; margin-bottom: 16px; display: none; font-size: 14px; }
/* 持续影响 */
#world-effects { margin-bottom: 16px; }
.effect-item { display: inline-block; background: #1e3a5f; border-radius: 6px; padding: 6px 10px; margin-right: 8px; margin-bottom: 8px; font-size: 12px; }
.effect-item .remaining { color: #f59e0b; margin-left: 4px; }
/* 主内容区 */
.main-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 700px) { .main-grid { grid-template-columns: 1fr; } }
.card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 16px; }
.card-title { font-size: 12px; font-weight: 600; color: #71717a; text-transform: uppercase; margin-bottom: 12px; }
/* 世界状态 */
.world-stats { display: flex; gap: 20px; }
.stat-value { font-size: 24px; font-weight: 600; color: #60a5fa; }
.stat-label { font-size: 12px; color: #71717a; }
/* 角色 */
.agent { background: #27272a; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.agent:last-child { margin-bottom: 0; }
.agent-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.agent-name { font-weight: 600; }
.agent-emotion { font-size: 12px; padding: 2px 8px; border-radius: 10px; background: #3f3f46; }
.emotion-happy { background: #166534; color: #86efac; }
.emotion-anxious { background: #7f1d1d; color: #fca5a5; }
.emotion-calm { background: #1e3a5f; color: #93c5fd; }
.agent-info { font-size: 13px; color: #a1a1aa; }
/* 行动日志 */
.actions-list { max-height: 300px; overflow-y: auto; }
.action-item { padding: 8px; background: #27272a; border-radius: 6px; margin-bottom: 6px; font-size: 13px; }
.action-agent { font-weight: 600; color: #a78bfa; }
.action-say { color: #fbbf24; }
.action-do { color: #34d399; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>AI Town</h1>
<div class="status-bar">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">未连接</span>
</div>
</header>
<!-- 输入区域 -->
<section class="input-section">
<div class="input-row">
<input type="text" id="msgInput" placeholder="输入消息..." />
<button class="btn-primary" onclick="sendMessage()">发送</button>
</div>
<div class="quick-btns">
<button class="btn-quick" onclick="sendQuick('支持')">支持</button>
<button class="btn-quick" onclick="sendQuick('混乱')">混乱</button>
<button class="btn-quick" onclick="sendQuick('下雨')">下雨</button>
</div>
</section>
<!-- ① 能量条 -->
<div id="global-meter"><div id="meter-bar"></div></div>
<!-- ② 世界事件提示 -->
<div id="world-event"></div>
<!-- ③ 持续影响 -->
<div id="world-effects"></div>
<!-- 主内容区 -->
<div class="main-grid">
<div>
<div class="card" style="margin-bottom: 16px;">
<div class="card-title">世界状态</div>
<div class="world-stats">
<div><div class="stat-value" id="tick">0</div><div class="stat-label">Tick</div></div>
<div><div class="stat-value" id="weather">-</div><div class="stat-label">天气</div></div>
<div><div class="stat-value" id="mood">0</div><div class="stat-label">情绪值</div></div>
</div>
</div>
<div class="card">
<div class="card-title">居民</div>
<div id="agents"></div>
</div>
</div>
<div class="card">
<div class="card-title">行动日志</div>
<div class="actions-list" id="actions"></div>
</div>
</div>
</div>
<script>
// WebSocket
let ws = null;
let reconnectDelay = 1000;
function connect() {
ws = new WebSocket('ws://localhost:3000');
ws.onopen = () => {
document.getElementById('statusDot').classList.add('connected');
document.getElementById('statusText').textContent = '已连接';
reconnectDelay = 1000;
};
ws.onclose = () => {
document.getElementById('statusDot').classList.remove('connected');
document.getElementById('statusText').textContent = '重连中...';
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
};
ws.onerror = () => ws.close();
ws.onmessage = (e) => updateUI(JSON.parse(e.data));
}
// 发送消息
function sendMessage() {
const input = document.getElementById('msgInput');
const text = input.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'comment', text, user: 'viewer' }));
input.value = '';
}
function sendQuick(text) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'comment', text, user: 'viewer' }));
}
// 更新 UI
const weatherMap = { sunny: '☀️', rainy: '🌧️' };
const actionHistory = [];
function updateUI(data) {
const { world_state, actions, global_event } = data;
// 世界状态
document.getElementById('tick').textContent = world_state.tick;
document.getElementById('weather').textContent = weatherMap[world_state.weather] || world_state.weather;
document.getElementById('mood').textContent = world_state.town_mood;
// ① 能量条
if (world_state.global_meter) {
const meter = world_state.global_meter;
const percent = Math.min(100, meter.value / meter.threshold * 100);
document.getElementById('meter-bar').style.width = percent + '%';
}
// ② 世界事件
const eventEl = document.getElementById('world-event');
if (global_event?.triggered && global_event.event) {
eventEl.textContent = '🌍 世界事件:' + global_event.event.description;
eventEl.style.display = 'block';
} else {
eventEl.style.display = 'none';
}
// ③ 持续影响
const effectsEl = document.getElementById('world-effects');
if (world_state.world_effects?.length > 0) {
effectsEl.innerHTML = world_state.world_effects.map(e =>
`<span class="effect-item">${e.name}<span class="remaining">(${e.remaining_ticks})</span></span>`
).join('');
} else {
effectsEl.innerHTML = '';
}
// 角色
const agentsEl = document.getElementById('agents');
agentsEl.innerHTML = '';
for (const [id, agent] of Object.entries(world_state.agents)) {
agentsEl.innerHTML += `
<div class="agent">
<div class="agent-header">
<span class="agent-name">${id}</span>
<span class="agent-emotion emotion-${agent.emotion}">${agent.emotion}</span>
</div>
<div class="agent-info">记忆: ${agent.memory.slice(-2).join(' | ') || '-'}</div>
</div>`;
}
// 行动日志(倒序:新的在上)
const now = new Date().toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).replace(/\//g, '-');
for (const a of actions) {
a.time = now;
actionHistory.unshift(a);
}
if (actionHistory.length > 50) actionHistory.length = 50;
document.getElementById('actions').innerHTML = actionHistory.map(a =>
`<div class="action-item"><span style="color:#71717a;font-size:11px;">${a.time}</span> <span class="action-agent">${a.agent_id}</span> <span class="action-say">「${a.say}」</span> <span class="action-do">→ ${a.do}</span></div>`
).join('');
}
// 回车发送
document.getElementById('msgInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendMessage();
});
// 启动
connect();
</script>
</body>
</html>