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>
This commit is contained in:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
state.json
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
9
README.md
Normal file
9
README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
## 验收标准(你跑通就算成功):
|
||||
|
||||
打开 http://localhost:3000 能看到页面
|
||||
在输入框发一句话(例如:下雨了 / 支持Alice / 制造混乱)
|
||||
页面实时更新:
|
||||
tick 递增
|
||||
weather / town_mood 改变
|
||||
actions 出现两名 NPC 的“行动 + 台词”
|
||||
Node 服务和 Python 服务都能一键启动(两个终端)
|
||||
0
engine-python/app/__init__.py
Normal file
0
engine-python/app/__init__.py
Normal file
206
engine-python/app/engine.py
Normal file
206
engine-python/app/engine.py
Normal file
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
from typing import List, Optional, Tuple
|
||||
from .models import (
|
||||
WorldState, AgentState, Event, Action, Emotion, Weather,
|
||||
GlobalEventResult, GlobalEventInfo
|
||||
)
|
||||
from .global_events import GLOBAL_EVENT_POOL, GlobalEvent
|
||||
|
||||
MAX_EVENTS = 20
|
||||
MAX_MEMORY_PER_AGENT = 3
|
||||
COOLDOWN_TICKS = 5
|
||||
|
||||
# 事件类型对应的能量值
|
||||
ENERGY_MAP = {
|
||||
"comment": 1,
|
||||
"support": 3,
|
||||
"chaos": 5,
|
||||
}
|
||||
DEFAULT_ENERGY = 1
|
||||
|
||||
|
||||
def update_world_effects(state: WorldState) -> None:
|
||||
"""更新世界效果生命周期,应用持续影响"""
|
||||
# 递减 remaining_ticks,移除过期效果
|
||||
active_effects = []
|
||||
for effect in state.world_effects:
|
||||
effect.remaining_ticks -= 1
|
||||
if effect.remaining_ticks > 0:
|
||||
active_effects.append(effect)
|
||||
# 应用持续情绪影响
|
||||
state.town_mood += effect.mood_modifier
|
||||
state.town_mood = max(-10, min(10, state.town_mood))
|
||||
state.world_effects = active_effects
|
||||
|
||||
|
||||
def process_events(state: WorldState, events: List[Event]) -> WorldState:
|
||||
"""处理事件并更新世界状态"""
|
||||
# tick 递增
|
||||
state.tick += 1
|
||||
|
||||
# cooldown 递减
|
||||
if state.global_meter.cooldown > 0:
|
||||
state.global_meter.cooldown -= 1
|
||||
|
||||
# 更新世界效果(持续影响)
|
||||
update_world_effects(state)
|
||||
|
||||
for event in events:
|
||||
text = event.text
|
||||
|
||||
# 累计能量
|
||||
energy = ENERGY_MAP.get(event.type, DEFAULT_ENERGY)
|
||||
state.global_meter.value += energy
|
||||
|
||||
# 天气规则
|
||||
if "下雨" in text:
|
||||
state.weather = Weather.RAINY
|
||||
if "天晴" in text:
|
||||
state.weather = Weather.SUNNY
|
||||
|
||||
# 情绪规则
|
||||
if "支持" in text:
|
||||
state.town_mood = min(10, state.town_mood + 1)
|
||||
if "混乱" in text or "讨厌" in text:
|
||||
state.town_mood = max(-10, state.town_mood - 2)
|
||||
|
||||
# 添加事件到全局事件列表
|
||||
event_str = f"{event.user}: {text}"
|
||||
state.events.append(event_str)
|
||||
|
||||
# 添加到每个 agent 的 memory
|
||||
for agent in state.agents.values():
|
||||
agent.memory.append(event_str)
|
||||
# 保持最多3条
|
||||
if len(agent.memory) > MAX_MEMORY_PER_AGENT:
|
||||
agent.memory = agent.memory[-MAX_MEMORY_PER_AGENT:]
|
||||
|
||||
# 保持全局事件最多20条
|
||||
if len(state.events) > MAX_EVENTS:
|
||||
state.events = state.events[-MAX_EVENTS:]
|
||||
|
||||
# 更新所有 agent 的 emotion
|
||||
update_emotions(state)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def check_and_trigger_global_event(
|
||||
state: WorldState
|
||||
) -> Tuple[WorldState, GlobalEventResult]:
|
||||
"""检查并触发世界级事件"""
|
||||
meter = state.global_meter
|
||||
result = GlobalEventResult(triggered=False, event=None)
|
||||
|
||||
# 检查触发条件
|
||||
if meter.value >= meter.threshold and meter.cooldown == 0:
|
||||
# 基于 tick 选择事件(确保可复现)
|
||||
event_index = state.tick % len(GLOBAL_EVENT_POOL)
|
||||
global_event = GLOBAL_EVENT_POOL[event_index]
|
||||
|
||||
# 应用即时效果
|
||||
global_event.apply_effect(state)
|
||||
|
||||
# 生成持续影响效果
|
||||
world_effect = global_event.create_world_effect()
|
||||
state.world_effects.append(world_effect)
|
||||
|
||||
# 重置能量条,设置冷却
|
||||
state.global_meter.value = 0
|
||||
state.global_meter.cooldown = COOLDOWN_TICKS
|
||||
|
||||
# 更新 emotion
|
||||
update_emotions(state)
|
||||
|
||||
result = GlobalEventResult(
|
||||
triggered=True,
|
||||
event=global_event.to_info()
|
||||
)
|
||||
|
||||
return state, result
|
||||
|
||||
|
||||
def update_emotions(state: WorldState) -> None:
|
||||
"""根据 town_mood 更新所有 agent 的 emotion"""
|
||||
if state.town_mood >= 4:
|
||||
emotion = Emotion.HAPPY
|
||||
elif state.town_mood <= -4:
|
||||
emotion = Emotion.ANXIOUS
|
||||
else:
|
||||
emotion = Emotion.CALM
|
||||
|
||||
for agent in state.agents.values():
|
||||
agent.emotion = emotion
|
||||
|
||||
|
||||
def generate_actions(state: WorldState) -> List[Action]:
|
||||
"""根据当前状态生成 agent 的行动"""
|
||||
actions = []
|
||||
|
||||
for agent_id in state.agents:
|
||||
agent = state.agents[agent_id]
|
||||
action = generate_agent_action(agent_id, agent, state)
|
||||
actions.append(action)
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
def generate_agent_action(
|
||||
agent_id: str, agent: AgentState, state: WorldState
|
||||
) -> Action:
|
||||
"""为单个 agent 生成行动(基于规则模板)"""
|
||||
weather = state.weather
|
||||
emotion = agent.emotion
|
||||
|
||||
# Alice 的行为模板
|
||||
if agent_id == "alice":
|
||||
if weather == Weather.RAINY:
|
||||
if emotion == Emotion.HAPPY:
|
||||
say = "虽然下雨了,但心情还是很好呢!"
|
||||
do = "在屋檐下哼着歌"
|
||||
elif emotion == Emotion.ANXIOUS:
|
||||
say = "这雨什么时候才能停啊..."
|
||||
do = "焦虑地看着窗外"
|
||||
else:
|
||||
say = "下雨了,待在室内吧。"
|
||||
do = "在室内看书"
|
||||
else: # sunny
|
||||
if emotion == Emotion.HAPPY:
|
||||
say = "今天天气真好,心情也很棒!"
|
||||
do = "在广场上散步"
|
||||
elif emotion == Emotion.ANXIOUS:
|
||||
say = "天气虽好,但总觉得不安..."
|
||||
do = "在小镇边缘徘徊"
|
||||
else:
|
||||
say = "天气不错,出去走走吧。"
|
||||
do = "在街道上闲逛"
|
||||
|
||||
# Bob 的行为模板
|
||||
elif agent_id == "bob":
|
||||
if weather == Weather.RAINY:
|
||||
if emotion == Emotion.HAPPY:
|
||||
say = "雨天也有雨天的乐趣!"
|
||||
do = "在咖啡馆和人聊天"
|
||||
elif emotion == Emotion.ANXIOUS:
|
||||
say = "这种天气让人心烦..."
|
||||
do = "独自待在角落"
|
||||
else:
|
||||
say = "下雨天适合思考。"
|
||||
do = "在窗边沉思"
|
||||
else: # sunny
|
||||
if emotion == Emotion.HAPPY:
|
||||
say = "阳光明媚,适合交朋友!"
|
||||
do = "主动和路人打招呼"
|
||||
elif emotion == Emotion.ANXIOUS:
|
||||
say = "人太多了,有点紧张..."
|
||||
do = "躲在树荫下观察"
|
||||
else:
|
||||
say = "今天适合出门。"
|
||||
do = "在市场闲逛"
|
||||
|
||||
# 默认行为(其他 agent)
|
||||
else:
|
||||
say = f"我是{agent_id},今天感觉{emotion.value}。"
|
||||
do = "四处走动"
|
||||
|
||||
return Action(agent_id=agent_id, say=say, do=do)
|
||||
108
engine-python/app/global_events.py
Normal file
108
engine-python/app/global_events.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""世界级事件池"""
|
||||
from typing import Callable, List, Dict, Any, Optional
|
||||
from .models import WorldState, Weather, GlobalEventInfo, WorldEffect
|
||||
|
||||
|
||||
class GlobalEvent:
|
||||
"""世界级事件定义"""
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
apply_effect: Callable[[WorldState], None],
|
||||
effect_type: str,
|
||||
effect_duration: int = 5,
|
||||
mood_modifier: int = 0
|
||||
):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.apply_effect = apply_effect
|
||||
self.effect_type = effect_type
|
||||
self.effect_duration = effect_duration
|
||||
self.mood_modifier = mood_modifier
|
||||
|
||||
def to_info(self) -> GlobalEventInfo:
|
||||
return GlobalEventInfo(name=self.name, description=self.description)
|
||||
|
||||
def create_world_effect(self) -> WorldEffect:
|
||||
return WorldEffect(
|
||||
type=self.effect_type,
|
||||
name=self.name,
|
||||
intensity=abs(self.mood_modifier),
|
||||
remaining_ticks=self.effect_duration,
|
||||
mood_modifier=self.mood_modifier
|
||||
)
|
||||
|
||||
|
||||
# 事件效果函数
|
||||
def effect_festival(state: WorldState) -> None:
|
||||
"""节日效果:天气变晴,情绪+5"""
|
||||
state.weather = Weather.SUNNY
|
||||
state.town_mood = min(10, state.town_mood + 5)
|
||||
|
||||
|
||||
def effect_storm(state: WorldState) -> None:
|
||||
"""暴风雨效果:天气变雨,情绪-3"""
|
||||
state.weather = Weather.RAINY
|
||||
state.town_mood = max(-10, state.town_mood - 3)
|
||||
|
||||
|
||||
def effect_scandal(state: WorldState) -> None:
|
||||
"""丑闻效果:情绪-5"""
|
||||
state.town_mood = max(-10, state.town_mood - 5)
|
||||
|
||||
|
||||
def effect_miracle(state: WorldState) -> None:
|
||||
"""奇迹效果:天气变晴,情绪+3"""
|
||||
state.weather = Weather.SUNNY
|
||||
state.town_mood = min(10, state.town_mood + 3)
|
||||
|
||||
|
||||
def effect_fire(state: WorldState) -> None:
|
||||
"""火灾效果:情绪-4"""
|
||||
state.town_mood = max(-10, state.town_mood - 4)
|
||||
|
||||
|
||||
# 世界级事件池
|
||||
GLOBAL_EVENT_POOL: List[GlobalEvent] = [
|
||||
GlobalEvent(
|
||||
name="小镇节日",
|
||||
description="居民们欢聚一堂,举办盛大庆典!",
|
||||
apply_effect=effect_festival,
|
||||
effect_type="festival",
|
||||
effect_duration=5,
|
||||
mood_modifier=2,
|
||||
),
|
||||
GlobalEvent(
|
||||
name="暴风雨来袭",
|
||||
description="乌云密布,暴风雨席卷小镇。",
|
||||
apply_effect=effect_storm,
|
||||
effect_type="storm",
|
||||
effect_duration=4,
|
||||
mood_modifier=-1,
|
||||
),
|
||||
GlobalEvent(
|
||||
name="惊天丑闻",
|
||||
description="镇长的秘密被曝光,全镇哗然。",
|
||||
apply_effect=effect_scandal,
|
||||
effect_type="scandal",
|
||||
effect_duration=6,
|
||||
mood_modifier=-2,
|
||||
),
|
||||
GlobalEvent(
|
||||
name="彩虹奇迹",
|
||||
description="雨后天空出现绚丽彩虹。",
|
||||
apply_effect=effect_miracle,
|
||||
effect_type="miracle",
|
||||
effect_duration=3,
|
||||
mood_modifier=1,
|
||||
),
|
||||
GlobalEvent(
|
||||
name="火灾警报",
|
||||
description="镇中心发生火情,消防队紧急出动。",
|
||||
apply_effect=effect_fire,
|
||||
effect_type="fire",
|
||||
effect_duration=4,
|
||||
mood_modifier=-2,
|
||||
),
|
||||
]
|
||||
37
engine-python/app/main.py
Normal file
37
engine-python/app/main.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import FastAPI
|
||||
from .models import StepRequest, StepResponse
|
||||
from .state import load_state, save_state
|
||||
from .engine import process_events, generate_actions, check_and_trigger_global_event
|
||||
|
||||
app = FastAPI(title="AI Town Engine", version="0.1.0")
|
||||
|
||||
|
||||
@app.post("/step", response_model=StepResponse)
|
||||
def step(request: StepRequest) -> StepResponse:
|
||||
"""执行一个模拟步骤"""
|
||||
# 加载当前状态
|
||||
state = load_state()
|
||||
|
||||
# 处理事件并更新状态
|
||||
state = process_events(state, request.events)
|
||||
|
||||
# 检查并触发世界级事件
|
||||
state, global_event_result = check_and_trigger_global_event(state)
|
||||
|
||||
# 生成 agent 行动
|
||||
actions = generate_actions(state)
|
||||
|
||||
# 保存状态
|
||||
save_state(state)
|
||||
|
||||
return StepResponse(
|
||||
world_state=state,
|
||||
actions=actions,
|
||||
global_event=global_event_result
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""健康检查"""
|
||||
return {"status": "ok"}
|
||||
82
engine-python/app/models.py
Normal file
82
engine-python/app/models.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Dict, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Emotion(str, Enum):
|
||||
CALM = "calm"
|
||||
HAPPY = "happy"
|
||||
ANXIOUS = "anxious"
|
||||
|
||||
|
||||
class Weather(str, Enum):
|
||||
SUNNY = "sunny"
|
||||
RAINY = "rainy"
|
||||
|
||||
|
||||
class GlobalMeter(BaseModel):
|
||||
"""全体能量条"""
|
||||
value: int = 0
|
||||
threshold: int = 100
|
||||
cooldown: int = 0
|
||||
|
||||
|
||||
class WorldEffect(BaseModel):
|
||||
"""持续影响效果"""
|
||||
type: str
|
||||
name: str
|
||||
intensity: int = 1
|
||||
remaining_ticks: int = 5
|
||||
mood_modifier: int = 0
|
||||
|
||||
|
||||
class AgentState(BaseModel):
|
||||
emotion: Emotion = Emotion.CALM
|
||||
goal: str = ""
|
||||
memory: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorldState(BaseModel):
|
||||
tick: int = 0
|
||||
weather: Weather = Weather.SUNNY
|
||||
town_mood: int = Field(default=0, ge=-10, le=10)
|
||||
agents: Dict[str, AgentState] = Field(default_factory=dict)
|
||||
events: List[str] = Field(default_factory=list)
|
||||
global_meter: GlobalMeter = Field(default_factory=GlobalMeter)
|
||||
world_effects: List[WorldEffect] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
type: str
|
||||
text: str
|
||||
user: str
|
||||
ts: float
|
||||
|
||||
|
||||
class StepRequest(BaseModel):
|
||||
events: List[Event] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Action(BaseModel):
|
||||
agent_id: str
|
||||
say: str
|
||||
do: str
|
||||
|
||||
|
||||
class GlobalEventInfo(BaseModel):
|
||||
"""世界级事件信息"""
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class GlobalEventResult(BaseModel):
|
||||
"""世界级事件触发结果"""
|
||||
triggered: bool = False
|
||||
event: Optional[GlobalEventInfo] = None
|
||||
|
||||
|
||||
class StepResponse(BaseModel):
|
||||
world_state: WorldState
|
||||
actions: List[Action]
|
||||
global_event: GlobalEventResult = Field(default_factory=GlobalEventResult)
|
||||
35
engine-python/app/state.py
Normal file
35
engine-python/app/state.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from .models import WorldState, AgentState, GlobalMeter
|
||||
|
||||
STATE_FILE = Path(__file__).parent.parent / "state.json"
|
||||
|
||||
|
||||
def get_default_state() -> WorldState:
|
||||
"""创建默认的世界状态"""
|
||||
return WorldState(
|
||||
tick=0,
|
||||
weather="sunny",
|
||||
town_mood=0,
|
||||
agents={
|
||||
"alice": AgentState(emotion="calm", goal="探索小镇", memory=[]),
|
||||
"bob": AgentState(emotion="calm", goal="与人交流", memory=[]),
|
||||
},
|
||||
events=[],
|
||||
global_meter=GlobalMeter(value=0, threshold=100, cooldown=0),
|
||||
)
|
||||
|
||||
|
||||
def load_state() -> WorldState:
|
||||
"""从文件加载状态,如果不存在则创建默认状态"""
|
||||
if STATE_FILE.exists():
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return WorldState(**data)
|
||||
return get_default_state()
|
||||
|
||||
|
||||
def save_state(state: WorldState) -> None:
|
||||
"""保存状态到文件"""
|
||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(state.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
5
engine-python/requirements.txt
Normal file
5
engine-python/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn>=0.24.0
|
||||
pydantic>=2.5.0
|
||||
pytest>=7.4.0
|
||||
httpx>=0.25.0
|
||||
0
engine-python/tests/__init__.py
Normal file
0
engine-python/tests/__init__.py
Normal file
173
engine-python/tests/test_step.py
Normal file
173
engine-python/tests/test_step.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.main import app
|
||||
from app.state import STATE_FILE, get_default_state, save_state
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state():
|
||||
"""每个测试前重置状态文件"""
|
||||
if STATE_FILE.exists():
|
||||
STATE_FILE.unlink()
|
||||
save_state(get_default_state())
|
||||
yield
|
||||
if STATE_FILE.exists():
|
||||
STATE_FILE.unlink()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_step_tick_increments(client):
|
||||
"""测试 tick 递增"""
|
||||
# 第一次调用
|
||||
resp1 = client.post("/step", json={"events": []})
|
||||
assert resp1.status_code == 200
|
||||
data1 = resp1.json()
|
||||
assert data1["world_state"]["tick"] == 1
|
||||
|
||||
# 第二次调用
|
||||
resp2 = client.post("/step", json={"events": []})
|
||||
assert resp2.status_code == 200
|
||||
data2 = resp2.json()
|
||||
assert data2["world_state"]["tick"] == 2
|
||||
|
||||
|
||||
def test_mood_bounded(client):
|
||||
"""测试 town_mood 有界 (-10 到 10)"""
|
||||
# 发送多个"支持"事件,测试上限
|
||||
events = [
|
||||
{"type": "comment", "text": "支持", "user": "test", "ts": i}
|
||||
for i in range(15)
|
||||
]
|
||||
resp = client.post("/step", json={"events": events})
|
||||
data = resp.json()
|
||||
assert data["world_state"]["town_mood"] <= 10
|
||||
|
||||
# 重置状态
|
||||
save_state(get_default_state())
|
||||
|
||||
# 发送多个"混乱"事件,测试下限
|
||||
events = [
|
||||
{"type": "comment", "text": "混乱", "user": "test", "ts": i}
|
||||
for i in range(15)
|
||||
]
|
||||
resp = client.post("/step", json={"events": events})
|
||||
data = resp.json()
|
||||
assert data["world_state"]["town_mood"] >= -10
|
||||
|
||||
|
||||
def test_memory_max_length(client):
|
||||
"""测试 memory 长度不超过 3"""
|
||||
events = [
|
||||
{"type": "comment", "text": f"消息{i}", "user": "user1", "ts": i}
|
||||
for i in range(10)
|
||||
]
|
||||
resp = client.post("/step", json={"events": events})
|
||||
data = resp.json()
|
||||
|
||||
for agent_id, agent in data["world_state"]["agents"].items():
|
||||
assert len(agent["memory"]) <= 3
|
||||
|
||||
|
||||
def test_weather_change(client):
|
||||
"""测试天气变化"""
|
||||
# 下雨
|
||||
resp = client.post("/step", json={
|
||||
"events": [{"type": "comment", "text": "下雨了", "user": "test", "ts": 1}]
|
||||
})
|
||||
assert resp.json()["world_state"]["weather"] == "rainy"
|
||||
|
||||
# 天晴
|
||||
resp = client.post("/step", json={
|
||||
"events": [{"type": "comment", "text": "天晴了", "user": "test", "ts": 2}]
|
||||
})
|
||||
assert resp.json()["world_state"]["weather"] == "sunny"
|
||||
|
||||
|
||||
def test_actions_generated(client):
|
||||
"""测试 actions 生成"""
|
||||
resp = client.post("/step", json={"events": []})
|
||||
data = resp.json()
|
||||
|
||||
assert len(data["actions"]) == 2
|
||||
agent_ids = [a["agent_id"] for a in data["actions"]]
|
||||
assert "alice" in agent_ids
|
||||
assert "bob" in agent_ids
|
||||
|
||||
for action in data["actions"]:
|
||||
assert "say" in action
|
||||
assert "do" in action
|
||||
|
||||
|
||||
def test_global_meter_accumulates(client):
|
||||
"""测试能量累计"""
|
||||
# comment 类型 +1
|
||||
events = [{"type": "comment", "text": "hello", "user": "test", "ts": 1}]
|
||||
resp = client.post("/step", json={"events": events})
|
||||
data = resp.json()
|
||||
assert data["world_state"]["global_meter"]["value"] == 1
|
||||
|
||||
# support 类型 +3
|
||||
events = [{"type": "support", "text": "支持", "user": "test", "ts": 2}]
|
||||
resp = client.post("/step", json={"events": events})
|
||||
data = resp.json()
|
||||
assert data["world_state"]["global_meter"]["value"] == 4
|
||||
|
||||
# chaos 类型 +5
|
||||
events = [{"type": "chaos", "text": "混乱", "user": "test", "ts": 3}]
|
||||
resp = client.post("/step", json={"events": events})
|
||||
data = resp.json()
|
||||
assert data["world_state"]["global_meter"]["value"] == 9
|
||||
|
||||
|
||||
def test_global_event_triggers(client):
|
||||
"""测试世界级事件触发"""
|
||||
# 发送足够多的 chaos 事件达到阈值 (100)
|
||||
# chaos +5, 需要 20 个事件
|
||||
events = [
|
||||
{"type": "chaos", "text": f"chaos{i}", "user": "test", "ts": i}
|
||||
for i in range(20)
|
||||
]
|
||||
resp = client.post("/step", json={"events": events})
|
||||
data = resp.json()
|
||||
|
||||
# 验证事件触发
|
||||
assert data["global_event"]["triggered"] is True
|
||||
assert data["global_event"]["event"] is not None
|
||||
assert "name" in data["global_event"]["event"]
|
||||
assert "description" in data["global_event"]["event"]
|
||||
|
||||
# 验证能量重置和冷却设置
|
||||
assert data["world_state"]["global_meter"]["value"] == 0
|
||||
assert data["world_state"]["global_meter"]["cooldown"] == 5
|
||||
|
||||
|
||||
def test_global_event_cooldown(client):
|
||||
"""测试冷却期间不触发事件"""
|
||||
# 先触发一次事件
|
||||
events = [
|
||||
{"type": "chaos", "text": f"chaos{i}", "user": "test", "ts": i}
|
||||
for i in range(20)
|
||||
]
|
||||
resp = client.post("/step", json={"events": events})
|
||||
assert resp.json()["global_event"]["triggered"] is True
|
||||
|
||||
# 冷却期间再次达到阈值,不应触发
|
||||
events = [
|
||||
{"type": "chaos", "text": f"chaos{i}", "user": "test", "ts": i}
|
||||
for i in range(20)
|
||||
]
|
||||
resp = client.post("/step", json={"events": events})
|
||||
data = resp.json()
|
||||
|
||||
# 不应触发(冷却中)
|
||||
assert data["global_event"]["triggered"] is False
|
||||
# 冷却递减
|
||||
assert data["world_state"]["global_meter"]["cooldown"] == 4
|
||||
52
server-node/README.md
Normal file
52
server-node/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# AI Town Server (Node.js)
|
||||
|
||||
WebSocket 服务器,连接前端和 Python 引擎。
|
||||
|
||||
## 功能
|
||||
|
||||
- HTTP 静态托管 `../web` 目录
|
||||
- WebSocket 接收客户端消息并入队
|
||||
- 每 2 秒批量调用 Python 引擎并广播结果
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 开发模式(热重载)
|
||||
npm run dev
|
||||
|
||||
# 生产构建
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## 依赖服务
|
||||
|
||||
启动前需先运行 Python 引擎:
|
||||
|
||||
```bash
|
||||
cd ../engine-python
|
||||
uvicorn app.main:app --port 8000
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
- `GET /health` - 健康检查,返回 `ok`
|
||||
- `WebSocket /` - 游戏消息通道
|
||||
|
||||
## WebSocket 协议
|
||||
|
||||
**客户端发送:**
|
||||
```json
|
||||
{ "type": "comment", "text": "你好", "user": "玩家A" }
|
||||
```
|
||||
|
||||
**服务端广播:**
|
||||
```json
|
||||
{
|
||||
"world_state": { "tick": 1, "weather": "sunny", ... },
|
||||
"actions": [{ "agent_id": "alice", "say": "...", "do": "..." }]
|
||||
}
|
||||
```
|
||||
1547
server-node/package-lock.json
generated
Normal file
1547
server-node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
server-node/package.json
Normal file
22
server-node/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "ai-town-server",
|
||||
"version": "1.0.0",
|
||||
"description": "AI Town WebSocket server",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"tsx": "^4.6.2",
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
}
|
||||
17
server-node/src/engine.ts
Normal file
17
server-node/src/engine.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { StepRequest, StepResponse } from './types';
|
||||
|
||||
const ENGINE_URL = 'http://localhost:8000/step';
|
||||
|
||||
export async function callEngine(request: StepRequest): Promise<StepResponse> {
|
||||
const response = await fetch(ENGINE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Engine returned ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<StepResponse>;
|
||||
}
|
||||
82
server-node/src/index.ts
Normal file
82
server-node/src/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { eventQueue } from './queue';
|
||||
import { callEngine } from './engine';
|
||||
import { ClientMessage, GameEvent } from './types';
|
||||
|
||||
const PORT = 3000;
|
||||
const TICK_INTERVAL = 2000;
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
// 静态托管 ../web 目录
|
||||
const webDir = path.join(__dirname, '../../web');
|
||||
app.use(express.static(webDir));
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (_req, res) => {
|
||||
res.send('ok');
|
||||
});
|
||||
|
||||
// WebSocket 连接处理
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('[WS] Client connected');
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg: ClientMessage = JSON.parse(data.toString());
|
||||
if (msg.type === 'comment' && msg.text && msg.user) {
|
||||
const event: GameEvent = {
|
||||
type: msg.type,
|
||||
text: msg.text,
|
||||
user: msg.user,
|
||||
ts: Date.now(),
|
||||
};
|
||||
eventQueue.push(event);
|
||||
console.log(`[Queue] Added event, size: ${eventQueue.size()}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WS] Invalid message:', err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('[WS] Client disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
// 广播消息给所有客户端
|
||||
function broadcast(data: object): void {
|
||||
const message = JSON.stringify(data);
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 定时调用 Python 引擎
|
||||
async function tick(): Promise<void> {
|
||||
const events = eventQueue.drain();
|
||||
console.log(`[Tick] Processing ${events.length} events`);
|
||||
|
||||
try {
|
||||
const response = await callEngine({ events });
|
||||
console.log(`[Tick] Step success, tick=${response.world_state.tick}`);
|
||||
broadcast(response);
|
||||
} catch (err) {
|
||||
console.error('[Tick] Engine call failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(tick, TICK_INTERVAL);
|
||||
|
||||
// 启动服务器
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[Server] HTTP + WebSocket running on http://localhost:${PORT}`);
|
||||
console.log(`[Server] Serving static files from ${webDir}`);
|
||||
});
|
||||
28
server-node/src/queue.ts
Normal file
28
server-node/src/queue.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { GameEvent } from './types';
|
||||
|
||||
const MAX_QUEUE_SIZE = 200;
|
||||
|
||||
class EventQueue {
|
||||
private queue: GameEvent[] = [];
|
||||
|
||||
push(event: GameEvent): void {
|
||||
this.queue.push(event);
|
||||
if (this.queue.length > MAX_QUEUE_SIZE) {
|
||||
const dropped = this.queue.length - MAX_QUEUE_SIZE;
|
||||
this.queue = this.queue.slice(dropped);
|
||||
console.log(`[Queue] Dropped ${dropped} oldest events`);
|
||||
}
|
||||
}
|
||||
|
||||
drain(): GameEvent[] {
|
||||
const events = this.queue;
|
||||
this.queue = [];
|
||||
return events;
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
}
|
||||
|
||||
export const eventQueue = new EventQueue();
|
||||
41
server-node/src/types.ts
Normal file
41
server-node/src/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface GameEvent {
|
||||
type: string;
|
||||
text: string;
|
||||
user: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface StepRequest {
|
||||
events: GameEvent[];
|
||||
}
|
||||
|
||||
export interface AgentState {
|
||||
emotion: string;
|
||||
goal: string;
|
||||
memory: string[];
|
||||
}
|
||||
|
||||
export interface WorldState {
|
||||
tick: number;
|
||||
weather: string;
|
||||
town_mood: number;
|
||||
agents: Record<string, AgentState>;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
agent_id: string;
|
||||
say: string;
|
||||
do: string;
|
||||
}
|
||||
|
||||
export interface StepResponse {
|
||||
world_state: WorldState;
|
||||
actions: Action[];
|
||||
}
|
||||
|
||||
export interface ClientMessage {
|
||||
type: string;
|
||||
text: string;
|
||||
user: string;
|
||||
}
|
||||
16
server-node/tsconfig.json
Normal file
16
server-node/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
249
web/index.html
Normal file
249
web/index.html
Normal file
@@ -0,0 +1,249 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user