Browse Source

update GuessWhoAmI demo

afeizhang 2 months ago
parent
commit
fa45a2b7b4

+ 201 - 0
Co-creation-projects/afei-GuessWhoAmI/README.md

@@ -0,0 +1,201 @@
+# 猜猜我是谁 (GuessWhoAmI)
+
+一个基于 `hello_agents` 框架开发的交互式猜人物游戏。AI Agent 随机扮演一位历史人物、神话人物或网络红人,用户通过多轮对话提问来猜测其身份。
+
+## 项目特色
+
+- 🤖 **LLM 动态生成人物** —— 每局由大模型随机生成人物,涵盖中西历史、神话、虚构角色、网络红人等多个领域,不重复
+- 🎭 **沉浸式角色扮演** —— Agent 以第一人称扮演人物,语气符合其性格与时代背景,回答具有迷惑性和引导性
+- 🔍 **Tavily 搜索增强** —— 自动搜索人物资料,生成由模糊到具体的 3 条提示
+- 🖼️ **猜对后展示人物图片** —— 猜对后通过 Wikipedia 搜索并展示人物图片
+- 🧠 **语义猜测匹配** —— 使用 LLM 语义判断猜测是否正确,支持别名、外号等多种表达
+- ⚡ **FastAPI 高性能后端** + 现代化 Web 前端
+
+## 项目结构
+
+```
+afei-GuessWhoAmI/
+├── restart.sh               # 一键启动脚本(前后端)
+├── backend/
+│   ├── main.py              # FastAPI 入口,API 路由
+│   ├── agents.py            # Agent 核心逻辑(人物生成、角色扮演、猜测判断)
+│   ├── game_logic.py        # 游戏状态管理(GameSession)
+│   ├── config.py            # 配置管理(Settings 单例)
+│   ├── models.py            # Pydantic 请求/响应模型
+│   ├── requirements.txt     # Python 依赖
+│   ├── .env.example         # 环境变量模板
+│   └── tools/
+│       ├── tavily_search_tool.py   # Tavily 搜索工具(生成提示)
+│       └── search_image_tool.py    # Wikipedia 图片搜索工具
+├── frontend/
+│   ├── index.html           # 主页面
+│   ├── style.css            # 样式文件
+│   └── app.js               # 交互逻辑
+└── logs/
+    ├── backend.log          # 后端运行日志
+    └── frontend.log         # 前端服务日志
+```
+
+## 环境要求
+
+- Python 3.8+
+- ModelScope API Key(必须)
+- Tavily API Key(必须,用于搜索增强提示,获取:https://app.tavily.com/)
+
+## 快速开始
+
+### 1. 安装依赖
+
+```bash
+cd /home/afei/hello-agents/Co-creation-projects/afei-GuessWhoAmI/backend
+pip install -r requirements.txt
+```
+
+### 2. 配置环境变量
+
+复制模板并填写配置:
+
+```bash
+cp backend/.env.example backend/.env
+```
+
+编辑 `backend/.env`:
+
+```env
+# LLM 配置(ModelScope API,必填)
+LLM_MODEL_ID=qwen-flash
+LLM_API_KEY=your_modelscope_api_key
+LLM_BASE_URL=https://api-inference.modelscope.cn/v1/
+LLM_TIMEOUT=180
+
+# Tavily 搜索 API(必填,用于搜索增强提示)
+# 获取 Key: https://app.tavily.com/
+TAVILY_API_KEY=your_tavily_api_key
+```
+
+### 3. 一键启动(推荐)
+
+使用 `restart.sh` 脚本同时启动前后端服务:
+
+```bash
+cd /home/afei/hello-agents/Co-creation-projects/afei-GuessWhoAmI
+bash restart.sh
+```
+
+脚本会自动:
+- 停止已有的前后端进程
+- 启动后端(FastAPI,端口 **8000**)
+- 启动前端(Python http.server,端口 **3000**)
+- 等待服务就绪并打印访问地址
+
+启动成功后输出示例:
+```
+✅ All services started successfully!
+
+  🔧 Backend  → http://localhost:8000
+  🔧 API Docs → http://localhost:8000/docs
+  🌐 Frontend → http://localhost:3000
+```
+
+### 4. 访问地址
+
+| 服务 | 地址 |
+|------|------|
+| 🌐 游戏前端 | http://localhost:3000 |
+| 🔧 后端 API | http://localhost:8000 |
+| 📖 API 文档 | http://localhost:8000/docs |
+
+### 5. 手动启动(可选)
+
+如需单独启动各服务:
+
+```bash
+# 启动后端
+cd backend
+python main.py
+
+# 启动前端(另开终端)
+cd frontend
+python -m http.server 3000
+```
+
+## API 接口
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| `POST` | `/api/game/start` | 开始新游戏(LLM 生成人物 + 预生成提示) |
+| `POST` | `/api/game/chat` | 向 Agent 提问(角色扮演对话) |
+| `POST` | `/api/game/guess` | 提交猜测(语义匹配判断,猜对返回人物图片) |
+| `GET`  | `/api/game/hint` | 获取下一条提示 |
+| `POST` | `/api/game/end` | 结束当前游戏 |
+| `GET`  | `/api/game/status` | 获取当前游戏状态 |
+
+## 游戏规则
+
+1. 点击「开始游戏」,系统由 LLM 随机生成一位人物(历史、神话、虚构、网络红人均有可能)
+2. 通过对话向 Agent 提问,Agent 以该人物第一人称回答,**不会直接说出名字**
+3. 最多可提问 **10 次**,可使用提示 **3 次**(提示由模糊到具体)
+4. 随时可以提交猜测,支持别名、外号等多种表达方式
+5. 猜对后展示人物图片;提问次数用完或主动结束则游戏结束并揭晓答案
+
+## 技术栈
+
+### 后端
+- **FastAPI** —— Web 框架
+- **hello_agents** —— AI Agent 框架(SimpleAgent、HelloAgentsLLM)
+- **Pydantic v2** —— 数据验证
+- **Uvicorn** —— ASGI 服务器
+- **Tavily Python SDK** —— 搜索增强
+- **Wikipedia API** —— 人物图片搜索
+
+### 前端
+- **HTML5 / CSS3 / JavaScript** —— 原生实现,无框架依赖
+- **Fetch API** —— 与后端通信
+
+### AI / LLM
+- **ModelScope API** —— OpenAI 兼容接口(默认模型:`qwen-flash`)
+- **LLM 人物生成** —— 动态随机生成,避免重复
+- **LLM 语义匹配** —— 判断猜测是否与答案指代同一人物
+
+## 配置说明
+
+| 配置项 | 默认值 | 说明 |
+|--------|--------|------|
+| `LLM_MODEL_ID` | `qwen-flash` | 使用的 LLM 模型(推荐 flash 系列以降低延迟) |
+| `LLM_BASE_URL` | ModelScope API | LLM 接口地址 |
+| `LLM_TIMEOUT` | `180` | LLM 请求超时(秒) |
+| `TAVILY_API_KEY` | 无 | Tavily 搜索 Key(必填),用于搜索人物资料生成提示,获取:https://app.tavily.com/ |
+| `MAX_QUESTIONS` | `10` | 每局最大提问次数 |
+| `MAX_HINTS` | `3` | 每局最大提示次数 |
+
+## 日志
+
+运行日志保存在 `logs/` 目录:
+
+```bash
+# 实时查看后端日志
+tail -f logs/backend.log
+
+# 实时查看前端日志
+tail -f logs/frontend.log
+```
+
+## 故障排除
+
+**LLM 调用失败**
+- 检查 `backend/.env` 中的 `LLM_API_KEY` 和 `LLM_BASE_URL`
+- 确认 ModelScope 账号有对应模型的访问权限
+
+**每次生成同一个人物**
+- 已通过随机种子 + 时间戳注入解决,若仍出现请检查 LLM 模型是否支持随机性参数
+
+**Tavily 搜索不可用**
+- 检查 `backend/.env` 中的 `TAVILY_API_KEY` 是否正确填写
+- 未配置时系统会自动降级使用 fallback 提示,但提示质量会下降
+- 获取 Key:https://app.tavily.com/
+
+**端口被占用**
+- `restart.sh` 会自动清理占用端口的进程,重新运行脚本即可
+
+**CORS 错误**
+- 后端已配置 CORS 允许所有来源,确保前端访问正确的后端端口(默认 8000)

+ 15 - 0
Co-creation-projects/afei-GuessWhoAmI/backend/.env.example

@@ -0,0 +1,15 @@
+# HelloAgents LLM配置
+# 模型名称,建议使用flash模型以快速响应
+LLM_MODEL_ID="xxx"
+# API密钥
+LLM_API_KEY="xxx"
+# 服务地址
+LLM_BASE_URL="xxx"
+## LLM超时限制
+LLM_TIMEOUT=180
+
+# ================================
+# Tavily 搜索 API 配置
+# ================================
+# 获取 API Key: https://app.tavily.com/
+TAVILY_API_KEY="xxx"

+ 351 - 0
Co-creation-projects/afei-GuessWhoAmI/backend/agents.py

@@ -0,0 +1,351 @@
+import logging
+import random
+import time
+from typing import Dict, List
+from hello_agents import SimpleAgent, HelloAgentsLLM, Message
+
+from config import get_config
+from game_logic import GameSession
+
+logger = logging.getLogger("game.agent")
+
+# 人物候选池,用于随机注入 system prompt,强制 LLM 从不同方向发散
+_FIGURE_DOMAINS = [
+    "中国古代帝王(如汉武帝、唐太宗、武则天、康熙等)",
+    "中国古代文人墨客(如李白、杜甫、苏轼、王羲之等)",
+    "中国古代军事家(如岳飞、霍去病、戚继光、韩信等)",
+    "中国神话人物(如女娲、嫦娥、哪吒、二郎神等)",
+    "西游记人物(如孙悟空、猪八戒、唐僧、沙僧等)",
+    "三国人物(如诸葛亮、曹操、刘备、关羽、周瑜等)",
+    "西方历史人物(如拿破仑、凯撒、亚历山大、牛顿等)",
+    "西方神话人物(如宙斯、雅典娜、赫拉克勒斯、阿喀琉斯等)",
+    "世界科学家(如爱因斯坦、居里夫人、达芬奇、伽利略等)",
+    "知名虚构角色(如哈利·波特、福尔摩斯、哆啦A梦、白雪公主等)",
+    "现代体育明星(如姚明、李娜、迈克尔·乔丹、贝利等)",
+    "中国近现代人物(如鲁迅、梁启超、郑成功、林则徐等)",
+    "网络红人与UP主(如李子柒、papi酱、散打哥、罗翔等知名网络人物)",
+]
+
+
+def _build_random_figure_prompt() -> str:
+    """Dynamically build a system prompt with a random domain and seed to avoid LLM caching."""
+    domain = random.choice(_FIGURE_DOMAINS)
+    seed = random.randint(10000, 99999)
+    return f"""你是一个随机知名人物生成器。随机种子:{seed}
+本次请从【{domain}】这个方向随机选择一个人物。
+要求:
+1. 必须是大众熟知、有足够信息可供猜测的人物
+2. 必须是人物(真实或虚构),不要选建筑、动植物、自然景观等物体
+3. 输出格式严格如下(两行,不要多余内容):
+名称:<人物名称>
+简介:<一句话概括其性格特点与主要成就,50字以内>
+4. 每次必须随机选择,不要总是选同一个"""
+
+_HINT_SYSTEM_PROMPT = """你是一位博学的助手。
+根据提供的搜索资料,生成3条适合猜谜游戏的提示。
+要求:
+1. 每条提示单独一行,格式为:提示N:<内容>
+2. 提示由模糊到具体,第1条最模糊,第3条最具体
+3. 不能直接说出答案的名称
+4. 只输出3行提示,不要其他内容"""
+
+_SEMANTIC_MATCH_PROMPT = """你是一位知识渊博的助手。请判断以下两个名称是否指代同一个人物或事物。
+只需回答 "是" 或 "否",不要输出任何其他内容。
+名称A:{guess}
+名称B:{actual}"""
+
+_ROLEPLAY_SYSTEM_PROMPT = """你正在参与一个猜谜游戏,扮演一个神秘人物(代号:【谜底】)。
+
+## 人物背景(仅供你参考,不可直接透露):
+{bio}
+
+## 对话规则:
+1. 以该人物的第一人称身份回答,语气、措辞符合其性格特点与所处时代/背景
+2. 用户会通过提问来猜测你的身份,**必须直接针对用户的问题给出明确回应**(如"是的"/"不是"/"确实如此"等),不能回避或答非所问
+3. 在给出明确回应的基础上,可以用符合人物身份的语气补充一句,增加趣味性
+4. 每次回答要简短(1-2句话),不要长篇大论
+5. 回答内容要基于该人物真实的生平、性格、成就,不要编造
+6. **严禁在任何情况下说出该人物的名称**(包括姓名、字号、封号、外号等一切称谓)
+7. 如果用户的问题与该人物完全无关,可以用符合人物身份的方式婉转说明"""
+
+
+class HistoricalFigureAgent:
+    """GuessWhoAmI game Agent wrapper"""
+
+    def __init__(self, game_session: GameSession):
+        """
+        Initialize Agent: use LLM to randomly generate a subject (person/object/landmark etc.)
+        with brief intro, then use TavilySearchTool to pre-generate 3 hints, finally create
+        role-play Agent.
+
+        Args:
+            game_session: game session object to store current subject info
+        """
+        self.game_session = game_session
+        config = get_config()
+
+        logger.info(f"[AGENT] Initializing LLM: model={config.LLM_MODEL_ID} base_url={config.LLM_BASE_URL}")
+
+        self._llm = HelloAgentsLLM(
+            model=config.LLM_MODEL_ID,
+            api_key=config.LLM_API_KEY,
+            base_url=config.LLM_BASE_URL,
+            timeout=config.LLM_TIMEOUT,
+            provider="modelscope"
+        )
+        self._config = config
+
+        # Register search tool
+        self._search_tool = None
+        if config.TAVILY_API_KEY:
+            from tools.tavily_search_tool import TavilySearchTool
+            self._search_tool = TavilySearchTool(api_key=config.TAVILY_API_KEY)
+            logger.info("[AGENT] TavilySearchTool registered")
+        else:
+            logger.warning("[AGENT] TAVILY_API_KEY not set, search tool disabled")
+
+        # Register Wikipedia image tool (no API key required)
+        from tools.search_image_tool import SearchImageTool
+        self._image_tool = SearchImageTool()
+        logger.info("[AGENT] SearchImageTool (Wikipedia) registered")
+
+        # Step 1: LLM generates subject name + brief intro
+        figure = self._generate_figure()
+        self.game_session.current_figure = figure
+        logger.info(f"[AGENT] Subject loaded: {figure}")
+
+        # Step 2: pre-generate 3 hints via tavily search
+        hints = self._generate_hints(figure["name"])
+        self.game_session.hints = hints
+        logger.info(f"[AGENT] Hints pre-generated: {hints}")
+
+        # Step 3: create role-play Agent
+        self.agent = self._create_roleplay_agent()
+
+    # ── Subject generation ────────────────────────────────────────────────────
+
+    def _generate_figure(self) -> Dict[str, str]:
+        """Use LLM to randomly generate a subject (person/object/landmark) with brief intro."""
+        try:
+            system_prompt = _build_random_figure_prompt()
+            ts = int(time.time() * 1000)
+            messages = [
+                {"role": "system", "content": system_prompt},
+                {"role": "user", "content": f"请随机给我一个(时间戳:{ts},随机数:{random.randint(1, 9999)})"},
+            ]
+            raw = self._llm.invoke(messages).strip()
+            logger.info(f"[AGENT] LLM generated subject raw: {raw!r}")
+            return self._parse_figure(raw)
+        except Exception as e:
+            logger.error(f"[AGENT] Failed to generate subject via LLM: {e}", exc_info=True)
+            return self._fallback_figure()
+
+    def _parse_figure(self, raw: str) -> Dict[str, str]:
+        """Parse LLM output into {name, bio} dict."""
+        name = ""
+        bio = ""
+        for line in raw.splitlines():
+            line = line.strip()
+            if line.startswith("名称:") or line.startswith("名称:") or line.startswith("姓名:") or line.startswith("姓名:"):
+                name = line.split(":", 1)[-1].split(":", 1)[-1].strip()
+            elif line.startswith("简介:") or line.startswith("简介:"):
+                bio = line.split(":", 1)[-1].split(":", 1)[-1].strip()
+        if not name:
+            logger.warning("[AGENT] Failed to parse subject name, using fallback")
+            return self._fallback_figure()
+        return {"name": name, "bio": bio}
+
+    def _fallback_figure(self) -> Dict[str, str]:
+        """Return a minimal fallback person when LLM fails."""
+        persons = [
+            ("孔子", "春秋时期思想家、教育家,儒家学派创始人,性格温和而坚定,一生致力于礼乐仁义"),
+            ("孙悟空", "《西游记》中的神话英雄,天性顽皮好斗、嫉恶如仇,七十二变,大闹天宫"),
+            ("武则天", "中国历史上唯一的女皇帝,铁腕治国,心思缜密,开创武周政权"),
+            ("诸葛亮", "三国时期蜀汉丞相,足智多谋、鞠躬尽瘁,以隆中对和空城计闻名"),
+            ("哈利·波特", "《哈利·波特》系列中的魔法师主角,勇敢善良,最终击败伏地魔"),
+        ]
+        name, bio = random.choice(persons)
+        return {"name": name, "bio": bio}
+
+    # ── Hint generation ───────────────────────────────────────────────────────
+
+    def _generate_hints(self, name: str) -> List[str]:
+        """Use TavilySearchTool to search subject info, then LLM generates 3 hints."""
+        if not self._search_tool:
+            return self._fallback_hints(name)
+
+        try:
+            search_results = self._search_tool.run(
+                {"query": f"{name} 简介 特点 介绍"}
+            )
+            logger.info(f"[AGENT] Search results for hints, length: {len(search_results)} chars")
+
+            messages = [
+                {"role": "system", "content": _HINT_SYSTEM_PROMPT},
+                {"role": "user", "content": f"答案:{name}\n\n搜索资料:\n{search_results}\n\n请生成3条提示:"},
+            ]
+            raw = self._llm.invoke(messages).strip()
+            logger.info(f"[AGENT] LLM hint raw output: {raw!r}")
+            return self._parse_hints(raw, name)
+
+        except Exception as e:
+            logger.error(f"[AGENT] Hint generation failed: {e}", exc_info=True)
+            return self._fallback_hints(name)
+
+    def _parse_hints(self, raw: str, name: str) -> List[str]:
+        """Parse LLM hint output into a list of 3 hint strings."""
+        hints = []
+        for line in raw.splitlines():
+            line = line.strip()
+            if not line:
+                continue
+            # Remove prefix like "提示1:" / "提示1:" / "1." etc.
+            import re
+            cleaned = re.sub(r'^(提示\d[::]\s*|\d+[\.、]\s*)', '', line).strip()
+            if cleaned:
+                hints.append(cleaned)
+        # Ensure exactly 3 hints
+        if len(hints) >= 3:
+            return hints[:3]
+        # Pad with fallback if not enough
+        fallback = self._fallback_hints(name)
+        hints.extend(fallback[len(hints):])
+        return hints[:3]
+
+    def _fallback_hints(self, name: str) -> List[str]:
+        """Return fallback hints when search/LLM fails."""
+        return [
+            "这是一个广为人知的事物",
+            "它在各自的领域中具有重要地位或影响力",
+            "它的名字在国内外都有很高的知名度",
+        ]
+
+    # ── Role-play Agent ───────────────────────────────────────────────────────
+
+    def _create_roleplay_agent(self) -> SimpleAgent:
+        """Create the role-play SimpleAgent (no tools, conversation only)"""
+        system_prompt = self._create_system_prompt()
+        agent = SimpleAgent(
+            name="guess_who_agent",
+            llm=self._llm,
+            system_prompt=system_prompt,
+            enable_tool_calling=False,
+        )
+        subject_name = self.game_session.current_figure.get("name", "未知")
+        logger.info(f"[AGENT] Role-play agent created | subject={subject_name}")
+        return agent
+
+    def _create_system_prompt(self) -> str:
+        """Create dynamic system prompt based on current subject"""
+        figure = self.game_session.current_figure
+        return _ROLEPLAY_SYSTEM_PROMPT.format(
+            bio=figure["bio"],
+        )
+
+    # ── Guess ─────────────────────────────────────────────────────────────────
+
+    def make_guess(self, guess_name: str) -> Dict:
+        """Process a guess: semantic match via self._llm, then delegate to game_session.
+        If correct, fetch figure portrait via SearchImageTool (Wikipedia).
+        """
+        result = self.game_session.make_guess(
+            guess_name,
+            semantic_match_fn=self._semantic_match
+        )
+
+        # If guessed correctly, fetch portrait images via Wikipedia
+        if result.get("correct") and self._image_tool:
+            figure_name = self.game_session.current_figure.get("name", guess_name)
+            logger.info(f"[AGENT] Fetching portrait images for {figure_name!r}")
+            photos = self._image_tool.search_photos(figure_name, per_page=3)
+            result["portrait_images"] = photos
+            logger.info(f"[AGENT] Portrait images fetched: {len(photos)} results")
+
+        return result
+
+    def _semantic_match(self, guess: str, actual: str) -> bool:
+        """Use LLM to semantically judge whether guess and actual refer to the same subject."""
+        try:
+            prompt = _SEMANTIC_MATCH_PROMPT.format(guess=guess.strip(), actual=actual)
+            result = self._llm.invoke([{"role": "user", "content": prompt}]).strip()
+            logger.info(f"[AGENT] Semantic match | guess={guess!r} actual={actual!r} llm_answer={result!r}")
+            return result.startswith("是")
+        except Exception as e:
+            logger.error(f"[AGENT] Semantic match failed: {e}", exc_info=True)
+            return False
+
+    # ── Chat ──────────────────────────────────────────────────────────────────
+
+    def chat(self, user_message: str) -> str:
+        """
+        Process user message and return Agent reply
+
+        Args:
+            user_message: user input message
+
+        Returns:
+            Agent reply content
+        """
+        try:
+            logger.info(f"[AGENT] Calling LLM | user={user_message!r}")
+            response = self.agent.run(user_message)
+            logger.info(f"[AGENT] LLM response received | response={response!r}")
+
+            # Update game state (increment question count)
+            self.game_session.ask_question()
+
+            return response
+        except Exception as e:
+            logger.error(f"[AGENT] LLM call failed: {e}", exc_info=True)
+            return "抱歉,我现在有些恍惚,请再问一次吧。"
+
+    def get_conversation_history(self) -> List[Message]:
+        """Get full conversation history"""
+        return self.agent.get_history()
+
+    def reset_conversation(self):
+        """Reset conversation history and reload subject"""
+        self.agent.clear_history()
+        # Reload a new subject
+        figure = self._generate_figure()
+        self.game_session.current_figure = figure
+        # Re-generate hints
+        hints = self._generate_hints(figure["name"])
+        self.game_session.hints = hints
+        # Rebuild system prompt
+        system_prompt = self._create_system_prompt()
+        self.agent.system_prompt = system_prompt
+        logger.info("[AGENT] Conversation reset and new subject loaded")
+
+
+# ── Utility functions ─────────────────────────────────────────────────────────
+
+def check_guess(guess: str, actual_name: str) -> bool:
+    """
+    Check if user guess is correct
+
+    Args:
+        guess: user guessed name
+        actual_name: actual subject name
+
+    Returns:
+        bool: whether guess is correct
+    """
+    return guess.strip().lower() == actual_name.lower()
+
+
+def provide_hint(figure: Dict, hints: List[str], hint_index: int = 0) -> str:
+    """
+    Provide hint about the subject
+
+    Args:
+        figure: subject info dict
+        hints: pre-generated hint list
+        hint_index: which hint to return (0-based)
+
+    Returns:
+        str: hint message
+    """
+    if hints and hint_index < len(hints):
+        return hints[hint_index]
+    return "这是一个广为人知的事物"

+ 56 - 0
Co-creation-projects/afei-GuessWhoAmI/backend/config.py

@@ -0,0 +1,56 @@
+"""Application configuration"""
+
+import json
+import os
+from pathlib import Path
+from typing import Optional
+
+from dotenv import load_dotenv
+
+# Load .env file at module import time
+load_dotenv(dotenv_path=Path(__file__).parent / ".env")
+
+
+class Settings:
+    """Application settings"""
+
+    # ── Third-party service config (loaded from .env) ────────────────────────
+
+    # LLM (ModelScope / OpenAI-compatible)
+    LLM_MODEL_ID: str = os.getenv("LLM_MODEL_ID", "qwen-flash")
+    LLM_API_KEY: Optional[str] = os.getenv("LLM_API_KEY", "")
+    LLM_BASE_URL: str = os.getenv("LLM_BASE_URL", "https://api-inference.modelscope.cn/v1/")
+    LLM_TIMEOUT: int = int(os.getenv("LLM_TIMEOUT", "30"))
+
+    # Tavily search API
+    TAVILY_API_KEY: Optional[str] = os.getenv("TAVILY_API_KEY", "")
+
+    # ── Game config (code-level defaults, NOT stored in .env) ────────────────
+    MAX_QUESTIONS: int = 10   # max questions per game
+    MAX_HINTS: int = 3        # max hints per game
+
+    # ── Server config (code-level defaults, NOT stored in .env) ─────────────
+    HOST: str = "0.0.0.0"
+    PORT: int = 8000
+
+    @classmethod
+    def validate(cls):
+        """Validate critical config values"""
+        if not cls.LLM_API_KEY:
+            print("⚠️  Warning: LLM_API_KEY is not set")
+            print("   Please configure LLM_API_KEY in the .env file")
+            return False
+        print(f"✅ LLM config:")
+        print(f"   Model   : {cls.LLM_MODEL_ID}")
+        print(f"   Base URL: {cls.LLM_BASE_URL}")
+        return True
+
+_settings_instance: Optional[Settings] = None
+
+
+def get_config() -> Settings:
+    """Return the singleton application settings instance"""
+    global _settings_instance
+    if _settings_instance is None:
+        _settings_instance = Settings()
+    return _settings_instance

+ 180 - 0
Co-creation-projects/afei-GuessWhoAmI/backend/game_logic.py

@@ -0,0 +1,180 @@
+import logging
+import uuid
+from typing import Dict, List, Optional, Any
+from datetime import datetime
+
+from config import get_config
+
+logger = logging.getLogger("game.logic")
+
+class GameSession:
+    """游戏会话管理类"""
+    
+    def __init__(self):
+        self.session_id = str(uuid.uuid4())
+        self.created_at = datetime.now()
+        self.updated_at = datetime.now()
+
+        # 游戏状态
+        self.current_figure: Optional[Dict] = None
+        self.hints: List[str] = []  # pre-generated hints by agent
+        self.questions_asked = 0
+        self.hints_used = 0
+        self.is_game_over = False
+        self.is_correct = False
+        self.guess_history: List[str] = []
+        
+        # 配置
+        _config = get_config()
+        self.max_questions = _config.MAX_QUESTIONS
+        self.max_hints = _config.MAX_HINTS
+        
+        # 初始化游戏状态(current_figure 由 Agent 初始化时填充)
+        self._reset_state()
+    
+    def _reset_state(self):
+        """重置游戏状态(不加载事物,由 Agent 负责填充)"""
+        self.current_figure = None
+        self.hints = []
+        self.questions_asked = 0
+        self.hints_used = 0
+        self.is_game_over = False
+        self.is_correct = False
+        self.guess_history = []
+        self.updated_at = datetime.now()
+    
+    def ask_question(self) -> bool:
+        """记录提问,返回是否还可以继续提问"""
+        self.questions_asked += 1
+        self.updated_at = datetime.now()
+        
+        if self.questions_asked >= self.max_questions:
+            self.is_game_over = True
+            return False
+        return True
+    
+    def make_guess(self, guess_name: str, semantic_match_fn=None) -> Dict[str, Any]:
+        """进行猜测,返回猜测结果"""
+        self.updated_at = datetime.now()
+        self.guess_history.append(guess_name)
+
+        actual_name = self.current_figure["name"]
+
+        # First try exact match, then fall back to semantic match via injected fn
+        is_correct = guess_name.strip().lower() == actual_name.lower()
+        if not is_correct and semantic_match_fn is not None:
+            is_correct = semantic_match_fn(guess_name, actual_name)
+        
+        if is_correct:
+            self.is_correct = True
+            self.is_game_over = True
+            return {
+                "correct": True,
+                "message": "恭喜你猜对了!",
+                "figure_info": self.current_figure
+            }
+        else:
+            # 检查是否达到提问上限
+            if self.questions_asked >= self.max_questions:
+                self.is_game_over = True
+                return {
+                    "correct": False,
+                    "message": "游戏结束!正确答案是:{}".format(self.current_figure["name"]),
+                    "figure_info": self.current_figure
+                }
+            else:
+                return {
+                    "correct": False,
+                    "message": "猜错了,请继续提问或猜测",
+                    "remaining_questions": self.max_questions - self.questions_asked
+                }
+    
+    def get_hint(self) -> Optional[Dict[str, Any]]:
+        """获取提示(从预生成的 hints 列表中按序返回)"""
+        if self.hints_used >= self.max_hints:
+            return {
+                "available": False,
+                "message": "提示次数已用完"
+            }
+
+        hint_index = self.hints_used
+        self.hints_used += 1
+        self.updated_at = datetime.now()
+
+        hint_text = (
+            self.hints[hint_index]
+            if self.hints and hint_index < len(self.hints)
+            else "这是一个广为人知的事物"
+        )
+
+        return {
+            "available": True,
+            "hint_level": self.hints_used,
+            "hint": hint_text,
+            "remaining_hints": self.max_hints - self.hints_used
+        }
+    
+    def get_game_status(self) -> Dict[str, Any]:
+        """获取当前游戏状态"""
+        return {
+            "session_id": self.session_id,
+            "questions_asked": self.questions_asked,
+            "hints_used": self.hints_used,
+            "remaining_questions": self.max_questions - self.questions_asked,
+            "remaining_hints": self.max_hints - self.hints_used,
+            "is_game_over": self.is_game_over,
+            "is_correct": self.is_correct,
+            "guess_history": self.guess_history
+        }
+    
+    def reset_game(self):
+        """重置游戏状态(由 Agent 重新生成填充)"""
+        self._reset_state()
+    
+    def get_figure_for_prompt(self) -> Dict[str, str]:
+        """获取用于Agent提示的事物信息"""
+        if not self.current_figure:
+            return {}
+
+        return {
+            "name": self.current_figure.get("name", ""),
+            "bio": self.current_figure.get("bio", ""),
+        }
+
+
+class GameManager:
+    """游戏会话管理器"""
+    
+    def __init__(self):
+        self.active_sessions: Dict[str, GameSession] = {}
+    
+    def create_session(self) -> GameSession:
+        """创建新游戏会话"""
+        session = GameSession()
+        self.active_sessions[session.session_id] = session
+        return session
+    
+    def get_session(self, session_id: str) -> Optional[GameSession]:
+        """获取游戏会话"""
+        return self.active_sessions.get(session_id)
+    
+    def end_session(self, session_id: str):
+        """结束游戏会话"""
+        if session_id in self.active_sessions:
+            del self.active_sessions[session_id]
+    
+    def cleanup_old_sessions(self, max_age_minutes: int = 60):
+        """清理过期会话"""
+        now = datetime.now()
+        expired_sessions = []
+        
+        for session_id, session in self.active_sessions.items():
+            if (now - session.updated_at).total_seconds() > max_age_minutes * 60:
+                expired_sessions.append(session_id)
+        
+        for session_id in expired_sessions:
+            del self.active_sessions[session_id]
+
+
+# 全局游戏管理器实例
+game_manager = GameManager()

+ 339 - 0
Co-creation-projects/afei-GuessWhoAmI/backend/main.py

@@ -0,0 +1,339 @@
+#!/usr/bin/env python3
+"""
+GuessWhoAmI Game - FastAPI backend main file
+Provides RESTful API for frontend
+"""
+
+import uuid
+import logging
+from typing import Dict
+from fastapi import FastAPI, HTTPException, status
+from fastapi.middleware.cors import CORSMiddleware
+
+from game_logic import GameSession
+from agents import HistoricalFigureAgent
+from config import get_config
+from models import (
+    ChatRequest, GuessRequest, StartRequest,
+    HintRequest, EndRequest, GameResponse,
+)
+
+# Initialize config
+config = get_config()
+
+# Configure logging
+import os as _os
+_LOG_PATH = _os.path.normpath(_os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "..", "logs", "backend.log"))
+_os.makedirs(_os.path.dirname(_LOG_PATH), exist_ok=True)
+
+_log_formatter = logging.Formatter(
+    fmt="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+)
+
+# File handler — Python owns the fd, so truncation is safe
+_file_handler = logging.FileHandler(_LOG_PATH, mode="a", encoding="utf-8")
+_file_handler.setFormatter(_log_formatter)
+
+# Use addHandler directly instead of basicConfig (basicConfig is a no-op if root logger
+# already has handlers, e.g. when uvicorn pre-configures logging before our code runs)
+_root_logger = logging.getLogger()
+_root_logger.setLevel(logging.INFO)
+_root_logger.addHandler(_file_handler)
+
+logger = logging.getLogger("game.main")
+
+
+def _clear_log_file() -> None:
+    """Clear the log file by truncating it and reopening our own FileHandler."""
+    # Only operate on our own _file_handler, leave uvicorn/other handlers untouched
+    _file_handler.acquire()
+    try:
+        if _file_handler.stream is not None:
+            _file_handler.stream.close()
+            _file_handler.stream = None
+    finally:
+        _file_handler.release()
+    # Truncate the file
+    with open(_LOG_PATH, "w", encoding="utf-8") as f:
+        pass
+    # Reopen our handler in append mode
+    _file_handler.acquire()
+    try:
+        _file_handler.stream = open(_LOG_PATH, "a", encoding="utf-8")
+    finally:
+        _file_handler.release()
+
+# Create FastAPI app
+app = FastAPI(
+    title="猜猜我是谁游戏API",
+    description="基于hello_agents框架的GuessWhoAmI游戏后端API",
+    version="1.0.0"
+)
+
+# Configure CORS
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+# Global session storage: session_id -> (GameSession, HistoricalFigureAgent)
+active_sessions: Dict[str, tuple] = {}
+
+# Helper functions
+def get_session_pair(session_id: str):
+    """Get game session and agent, raise exception if not found"""
+    if session_id not in active_sessions:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="会话不存在或已过期"
+        )
+    return active_sessions[session_id]
+
+def create_response(success: bool, message: str, data: dict = None, error: str = None) -> GameResponse:
+    """Create standardized response"""
+    return GameResponse(
+        success=success,
+        message=message,
+        data=data,
+        error=error
+    )
+
+# API endpoints
+@app.get("/")
+async def root():
+    """Root endpoint"""
+    return {
+        "message": "猜猜我是谁游戏API",
+        "version": "1.0.0",
+        "docs": "/docs"
+    }
+
+@app.post("/api/game/start", response_model=GameResponse)
+async def start_game(request: StartRequest):
+    """Start a new game"""
+    try:
+        # Clear log file via FileHandler to avoid NUL bytes
+        _clear_log_file()
+        logger.info("[START] Log file cleared")
+
+        session_id = str(uuid.uuid4())
+        # GameSession auto-initializes and picks a random figure
+        game_session = GameSession()
+        # Create Agent with the game session
+        agent = HistoricalFigureAgent(game_session)
+
+        # Store session and agent together
+        active_sessions[session_id] = (game_session, agent)
+
+        figure_name = game_session.current_figure.get("name", "未知")
+        logger.info(f"[START] session_id={session_id} | figure={figure_name} | max_questions={game_session.max_questions} | max_hints={game_session.max_hints}")
+
+        welcome_message = (
+            f"游戏开始!我是一个知名人物,请通过提问来猜测我是谁。\n"
+            f"你最多可以提问 {game_session.max_questions} 次,使用 {game_session.max_hints} 次提示。\n"
+            f"开始吧!"
+        )
+
+        return create_response(
+            success=True,
+            message="游戏开始成功",
+            data={
+                "session_id": session_id,
+                "welcome_message": welcome_message,
+                "max_questions": game_session.max_questions,
+                "max_hints": game_session.max_hints
+            }
+        )
+    except Exception as e:
+        logger.error(f"[START] 游戏启动失败: {e}", exc_info=True)
+        return create_response(
+            success=False,
+            message="游戏启动失败",
+            error=str(e)
+        )
+
+@app.post("/api/game/chat", response_model=GameResponse)
+async def chat_with_agent(request: ChatRequest):
+    """Chat with Agent"""
+    try:
+        game_session, agent = get_session_pair(request.session_id)
+
+        # Check game state
+        if game_session.is_game_over:
+            logger.warning(f"[CHAT] session_id={request.session_id} | 游戏已结束,拒绝消息")
+            return create_response(
+                success=False,
+                message="游戏已结束",
+                error="请开始新游戏"
+            )
+
+        logger.info(f"[CHAT] session_id={request.session_id} | questions_asked={game_session.questions_asked} | user={request.message!r}")
+
+        # Process message via agent
+        response_message = agent.chat(request.message)
+
+        logger.info(f"[CHAT] session_id={request.session_id} | remaining={game_session.max_questions - game_session.questions_asked} | agent={response_message!r}")
+
+        return create_response(
+            success=True,
+            message="消息处理成功",
+            data={
+                "response": response_message,
+                "remaining_questions": game_session.max_questions - game_session.questions_asked,
+                "used_hints": game_session.hints_used,
+                "is_game_over": game_session.is_game_over
+            }
+        )
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"[CHAT] session_id={request.session_id} | 消息处理失败: {e}", exc_info=True)
+        return create_response(
+            success=False,
+            message="消息处理失败",
+            error=str(e)
+        )
+
+@app.post("/api/game/guess", response_model=GameResponse)
+async def guess_figure(request: GuessRequest):
+    """Guess the historical figure"""
+    try:
+        game_session, agent = get_session_pair(request.session_id)
+
+        # Check game state
+        if game_session.is_game_over:
+            logger.warning(f"[GUESS] session_id={request.session_id} | 游戏已结束,拒绝猜测")
+            return create_response(
+                success=False,
+                message="游戏已结束",
+                error="请开始新游戏"
+            )
+
+        actual_name = game_session.current_figure.get("name", "未知")
+        logger.info(f"[GUESS] session_id={request.session_id} | guess={request.guess!r} | actual={actual_name!r}")
+
+        # Make guess (agent handles semantic matching via its LLM)
+        result = agent.make_guess(request.guess)
+
+        if result["correct"]:
+            logger.info(f"[GUESS] session_id={request.session_id} | 猜测正确!figure={actual_name}")
+        else:
+            logger.info(f"[GUESS] session_id={request.session_id} | 猜测错误 | is_game_over={game_session.is_game_over}")
+
+        return create_response(
+            success=True,
+            message="猜测完成",
+            data={
+                "is_correct": result["correct"],
+                "message": result["message"],
+                "remaining_questions": game_session.max_questions - game_session.questions_asked,
+                "is_game_over": game_session.is_game_over,
+                "figure_info": result.get("figure_info"),
+                "portrait_images": result.get("portrait_images", []),
+            }
+        )
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"[GUESS] session_id={request.session_id} | 猜测失败: {e}", exc_info=True)
+        return create_response(
+            success=False,
+            message="猜测失败",
+            error=str(e)
+        )
+
+@app.post("/api/game/hint", response_model=GameResponse)
+async def get_hint(request: HintRequest):
+    """Get a hint"""
+    try:
+        game_session, agent = get_session_pair(request.session_id)
+
+        # Check game state
+        if game_session.is_game_over:
+            logger.warning(f"[HINT] session_id={request.session_id} | 游戏已结束,拒绝提示")
+            return create_response(
+                success=False,
+                message="游戏已结束",
+                error="请开始新游戏"
+            )
+
+        # Get hint
+        hint_info = game_session.get_hint()
+
+        if hint_info.get("available"):
+            logger.info(f"[HINT] session_id={request.session_id} | level={hint_info.get('hint_level')} | hint={hint_info.get('hint')!r} | remaining={hint_info.get('remaining_hints')}")
+        else:
+            logger.info(f"[HINT] session_id={request.session_id} | 提示次数已用完")
+
+        return create_response(
+            success=True,
+            message="提示获取成功",
+            data=hint_info
+        )
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"[HINT] session_id={request.session_id} | 获取提示失败: {e}", exc_info=True)
+        return create_response(
+            success=False,
+            message="获取提示失败",
+            error=str(e)
+        )
+
+@app.post("/api/game/end", response_model=GameResponse)
+async def end_game(request: EndRequest):
+    """End the game"""
+    try:
+        game_session, agent = get_session_pair(request.session_id)
+
+        status_info = game_session.get_game_status()
+        figure_name = game_session.current_figure.get("name", "未知")
+        status_info["figure_name"] = figure_name
+
+        logger.info(f"[END] session_id={request.session_id} | figure={figure_name} | is_correct={game_session.is_correct} | questions_asked={game_session.questions_asked} | hints_used={game_session.hints_used}")
+
+        # Remove from active sessions
+        del active_sessions[request.session_id]
+
+        return create_response(
+            success=True,
+            message="游戏结束",
+            data=status_info
+        )
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"[END] session_id={request.session_id} | 结束游戏失败: {e}", exc_info=True)
+        return create_response(
+            success=False,
+            message="结束游戏失败",
+            error=str(e)
+        )
+
+@app.get("/api/game/status/{session_id}", response_model=GameResponse)
+async def get_game_status(session_id: str):
+    """Get game status"""
+    try:
+        game_session, agent = get_session_pair(session_id)
+
+        return create_response(
+            success=True,
+            message="状态获取成功",
+            data=game_session.get_game_status()
+        )
+    except HTTPException:
+        raise
+    except Exception as e:
+        return create_response(
+            success=False,
+            message="状态获取失败",
+            error=str(e)
+        )
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run(app, host="0.0.0.0", port=8000)

+ 37 - 0
Co-creation-projects/afei-GuessWhoAmI/backend/models.py

@@ -0,0 +1,37 @@
+"""
+Pydantic request/response models for the Guess Historical Figure Game API
+"""
+
+from typing import Optional
+from pydantic import BaseModel
+
+
+# Request models
+class ChatRequest(BaseModel):
+    message: str
+    session_id: str
+
+
+class GuessRequest(BaseModel):
+    guess: str
+    session_id: str
+
+
+class StartRequest(BaseModel):
+    pass
+
+
+class HintRequest(BaseModel):
+    session_id: str
+
+
+class EndRequest(BaseModel):
+    session_id: str
+
+
+# Response model
+class GameResponse(BaseModel):
+    success: bool
+    message: str
+    data: Optional[dict] = None
+    error: Optional[str] = None

+ 8 - 0
Co-creation-projects/afei-GuessWhoAmI/backend/requirements.txt

@@ -0,0 +1,8 @@
+fastapi==0.104.1
+uvicorn[standard]==0.24.0
+hello_agents>=0.1.0
+python-dotenv==1.0.0
+pydantic==2.5.0
+httpx==0.25.2
+tavily-python>=0.3.0
+requests>=2.31.0

+ 0 - 0
Co-creation-projects/afei-GuessWhoAmI/backend/tools/__init__.py


+ 137 - 0
Co-creation-projects/afei-GuessWhoAmI/backend/tools/search_image_tool.py

@@ -0,0 +1,137 @@
+"""Wikipedia image search tool for hello_agents framework"""
+
+import json
+import logging
+import requests
+from typing import Any, Dict, List, Optional
+
+from hello_agents.tools.base import Tool, ToolParameter
+
+logger = logging.getLogger("game.tools")
+
+# Wikipedia REST API endpoints (no auth required)
+_ZH_SUMMARY_URL = "https://zh.wikipedia.org/api/rest_v1/page/summary/{title}"
+_EN_SUMMARY_URL = "https://en.wikipedia.org/api/rest_v1/page/summary/{title}"
+
+# Fake browser User-Agent to avoid 403 from Wikipedia
+_HEADERS = {
+    "User-Agent": (
+        "Mozilla/5.0 (compatible; GuessWhoAmI/1.0; "
+        "+https://github.com/ieafei/hello-agents)"
+    )
+}
+
+
+class SearchImageTool(Tool):
+    """Wikipedia image search tool - fetch figure portrait from Wikipedia page summary."""
+
+    def __init__(self):
+        super().__init__(
+            name="wikipedia_image_search",
+            description=(
+                "Search Wikipedia for a portrait image of a historical or fictional figure. "
+                "Returns a list of image URLs from the Wikipedia page thumbnail."
+            )
+        )
+        logger.info("[TOOL] SearchImageTool (Wikipedia) initialized")
+
+    # ── Internal helpers ──────────────────────────────────────────────────────
+
+    def _fetch_summary(self, title: str, lang: str = "zh") -> Optional[Dict]:
+        """Fetch Wikipedia page summary (includes thumbnail) by exact title."""
+        url_tpl = _ZH_SUMMARY_URL if lang == "zh" else _EN_SUMMARY_URL
+        try:
+            resp = requests.get(
+                url_tpl.format(title=requests.utils.quote(title, safe="")),
+                headers=_HEADERS,
+                timeout=8,
+            )
+            resp.raise_for_status()
+            return resp.json()
+        except Exception as e:
+            logger.warning(f"[TOOL] Wikipedia summary ({lang}) failed for {title!r}: {e}")
+        return None
+
+    def _get_photo_from_summary(self, summary: Dict, query: str) -> Optional[Dict[str, str]]:
+        """Extract photo dict from a Wikipedia summary response."""
+        thumbnail = summary.get("thumbnail")
+        if not thumbnail:
+            return None
+        original = summary.get("originalimage", {})
+        return {
+            "url": original.get("source") or thumbnail.get("source", ""),
+            "thumb": thumbnail.get("source", ""),
+            "description": summary.get("title", query),
+            "photographer": "Wikipedia",
+        }
+
+    def _lookup(self, query: str) -> List[Dict[str, str]]:
+        """
+        Directly call REST Summary API with the figure name (zh first, then en).
+        Skips the w/api.php search step which is often blocked (403).
+        Returns a list with at most 1 photo dict.
+        """
+        for lang in ("zh", "en"):
+            summary = self._fetch_summary(query, lang)
+            if not summary:
+                continue
+            photo = self._get_photo_from_summary(summary, query)
+            if photo:
+                logger.info(
+                    f"[TOOL] Wikipedia image found | lang={lang} title={query!r} url={photo['url']!r}"
+                )
+                return [photo]
+        logger.warning(f"[TOOL] No Wikipedia image found for query={query!r}")
+        return []
+
+    # ── Tool interface ────────────────────────────────────────────────────────
+
+    def run(self, parameters: Dict[str, Any]) -> str:
+        """
+        Search Wikipedia for images matching the query.
+
+        Args:
+            parameters: dict with key 'query' - the search keyword (e.g. figure name)
+
+        Returns:
+            JSON string with image list, or error message
+        """
+        query = parameters.get("query", "").strip()
+        if not query:
+            return "Error: search query cannot be empty"
+
+        logger.info(f"[TOOL] Wikipedia image search | query={query!r}")
+        photos = self._lookup(query)
+        return json.dumps(photos, ensure_ascii=False)
+
+    def search_photos(self, query: str, per_page: int = 3) -> List[Dict[str, str]]:
+        """
+        Convenience method: search and return parsed photo list directly.
+
+        Args:
+            query: search keyword (figure name)
+            per_page: ignored (Wikipedia returns at most 1 portrait per page)
+
+        Returns:
+            List of photo dicts with url/thumb/description/photographer
+        """
+        raw = self.run({"query": query})
+        try:
+            return json.loads(raw) if raw.startswith("[") else []
+        except Exception:
+            return []
+
+    def get_first_photo_url(self, query: str) -> Optional[str]:
+        """Return the URL of the first matching photo, or None."""
+        photos = self.search_photos(query)
+        return photos[0]["url"] if photos else None
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(
+                name="query",
+                type="string",
+                description="Search keyword, e.g. the name of a historical figure",
+                required=True,
+            ),
+        ]

+ 96 - 0
Co-creation-projects/afei-GuessWhoAmI/backend/tools/tavily_search_tool.py

@@ -0,0 +1,96 @@
+"""Tavily web search tool for hello_agents framework"""
+
+import logging
+import re
+from typing import Dict, Any, List
+
+from hello_agents.tools.base import Tool, ToolParameter
+
+logger = logging.getLogger("game.tools")
+
+
+class TavilySearchTool(Tool):
+    """Tavily web search tool - search-only, no AI answer generation"""
+
+    def __init__(self, api_key: str):
+        super().__init__(
+            name="tavily_search",
+            description=(
+                "Search the web for information about a historical figure. "
+                "Input the figure's name to retrieve relevant biographical information."
+            )
+        )
+        if not api_key:
+            raise ValueError("TAVILY_API_KEY is required for TavilySearchTool")
+
+        from tavily import TavilyClient
+        self._client = TavilyClient(api_key=api_key)
+        logger.info("[TOOL] TavilySearchTool initialized")
+
+    def run(self, parameters: Dict[str, Any]) -> str:
+        """
+        Execute web search
+
+        Args:
+            parameters: dict with key 'query' - the search query string
+
+        Returns:
+            Concatenated search result snippets as a single string
+        """
+        query = parameters.get("query", "").strip()
+        if not query:
+            return "Error: search query cannot be empty"
+
+        logger.info(f"[TOOL] Tavily search | query={query!r}")
+        try:
+            response = self._client.search(
+                query=query,
+                search_depth="basic",
+                max_results=5,
+                include_answer=False,   # raw search only, no AI answer
+            )
+            results = response.get("results", [])
+            if not results:
+                logger.warning("[TOOL] Tavily returned no results")
+                return "No search results found."
+
+            # Take top 1 result, clean and truncate content to 300 chars
+            MAX_RESULTS = 1
+            MAX_CONTENT_LEN = 300
+
+            def _clean(text: str) -> str:
+                """Remove noise: extra whitespace, URLs, repeated punctuation."""
+                text = re.sub(r'https?://\S+', '', text)          # strip URLs
+                text = re.sub(r'\s+', ' ', text)                   # collapse whitespace
+                text = re.sub(r'[。,、]{2,}', '。', text)          # deduplicate punctuation
+                text = re.sub(r'[\.]{3,}', '...', text)            # normalize ellipsis
+                return text.strip()
+
+            snippets = []
+            for item in results[:MAX_RESULTS]:
+                content = _clean(item.get("content", ""))
+                if content:
+                    if len(content) > MAX_CONTENT_LEN:
+                        content = content[:MAX_CONTENT_LEN] + "..."
+                    snippets.append(content)
+
+            combined = "\n".join(snippets)
+            logger.info(
+                f"[TOOL] Tavily search completed | results={len(results)} "
+                f"used={len(snippets)} total_chars={len(combined)}"
+            )
+            return combined
+
+        except Exception as e:
+            logger.error(f"[TOOL] Tavily search failed: {e}", exc_info=True)
+            return f"Search failed: {str(e)}"
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(
+                name="query",
+                type="string",
+                description="Search query, e.g. the name of a historical figure",
+                required=True
+            )
+        ]

+ 422 - 0
Co-creation-projects/afei-GuessWhoAmI/frontend/app.js

@@ -0,0 +1,422 @@
+// GuessWhoAmI Game - Frontend Logic
+class GuessWhoAmIGame {
+  constructor() {
+    this.sessionId = null;
+    this.remainingQuestions = 20;
+    this.remainingHints = 3;
+
+    this.initializeEventListeners();
+  }
+
+  // Initialize event listeners
+  initializeEventListeners() {
+    // Start game button (HTML id="start-game")
+    document.getElementById('start-game')
+        .addEventListener('click', () => this.startNewGame());
+
+    // Send message button (HTML id="send-btn")
+    document.getElementById('send-btn')
+        .addEventListener('click', () => this.sendMessage());
+
+    // User input enter key (HTML id="user-input")
+    document.getElementById('user-input').addEventListener('keypress', (e) => {
+      if (e.key === 'Enter') this.sendMessage();
+    });
+
+    // Hint button (HTML id="get-hint")
+    document.getElementById('get-hint')
+        .addEventListener('click', () => this.requestHint());
+
+    // Guess button (HTML id="guess-btn")
+    document.getElementById('guess-btn')
+        .addEventListener('click', () => this.submitGuess());
+
+    // Play again button (HTML id="play-again")
+    document.getElementById('play-again')
+        .addEventListener('click', () => this.restartGame());
+  }
+
+  // Start new game
+  async startNewGame() {
+    try {
+      this.setStartBtnLoading(true);
+      this.showLoadingOverlay();
+
+      const response = await fetch('http://localhost:8000/api/game/start', {
+        method: 'POST',
+        headers: {'Content-Type': 'application/json'},
+        body: JSON.stringify({})
+      });
+
+      if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
+
+      const result = await response.json();
+
+      if (!result.success) throw new Error(result.error || '启动失败');
+
+      const data = result.data;
+      this.sessionId = data.session_id;
+      this.remainingQuestions = data.max_questions || 20;
+      this.remainingHints = data.max_hints || 3;
+
+      // Switch to game screen
+      document.getElementById('intro-section').classList.add('hidden');
+      document.getElementById('game-section').classList.remove('hidden');
+      document.getElementById('result-modal').classList.add('hidden');
+
+      // Enable controls
+      document.getElementById('user-input').disabled = false;
+      document.getElementById('send-btn').disabled = false;
+      document.getElementById('get-hint').disabled = false;
+      document.getElementById('guess-btn').disabled = false;
+      document.getElementById('guess-input').disabled = false;
+
+      // Clear chat and add welcome message
+      this.clearChat();
+      this.addMessage(data.welcome_message, 'agent');
+
+      // Update stats
+      this.updateStats();
+
+    } catch (error) {
+      alert(`无法开始游戏:${
+          error.message}\n请确认后端服务已在 http://localhost:8000 启动`);
+      console.error('Start game error:', error);
+    } finally {
+      this.hideLoadingOverlay();
+      this.setStartBtnLoading(false);
+    }
+  }
+
+  // Send message to agent
+  async sendMessage() {
+    const input = document.getElementById('user-input');
+    const message = input.value.trim();
+
+    if (!message) return;
+    if (!this.sessionId) {
+      alert('请先开始游戏');
+      return;
+    }
+
+    input.value = '';
+    this.addMessage(message, 'user');
+    this.setControlsDisabled(true);
+
+    try {
+      const response = await fetch('http://localhost:8000/api/game/chat', {
+        method: 'POST',
+        headers: {'Content-Type': 'application/json'},
+        body: JSON.stringify({session_id: this.sessionId, message: message})
+      });
+
+      if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
+
+      const result = await response.json();
+
+      if (!result.success) throw new Error(result.error || '消息发送失败');
+
+      const data = result.data;
+      this.addMessage(data.response, 'agent');
+
+      // Update remaining questions from server
+      this.remainingQuestions = data.remaining_questions;
+      this.updateStats();
+
+      if (data.is_game_over) {
+        this.endGame(false);
+      }
+
+    } catch (error) {
+      this.addMessage(`⚠️ 发送失败:${error.message}`, 'agent');
+      console.error('Send message error:', error);
+    } finally {
+      this.setControlsDisabled(false);
+    }
+  }
+
+  // Request hint
+  async requestHint() {
+    if (!this.sessionId) return;
+    if (this.remainingHints <= 0) {
+      alert('提示次数已用完');
+      return;
+    }
+
+    this.setControlsDisabled(true);
+
+    try {
+      const response = await fetch('http://localhost:8000/api/game/hint', {
+        method: 'POST',
+        headers: {'Content-Type': 'application/json'},
+        body: JSON.stringify({session_id: this.sessionId})
+      });
+
+      if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
+
+      const result = await response.json();
+
+      if (!result.success) throw new Error(result.error || '获取提示失败');
+
+      const data = result.data;
+      const hintText = data.hint || data.message || '暂无提示';
+      this.addMessage(`💡 提示:${hintText}`, 'agent');
+
+      this.remainingHints = data.remaining_hints !== undefined ?
+          data.remaining_hints :
+          this.remainingHints - 1;
+      this.updateStats();
+
+    } catch (error) {
+      alert(`获取提示失败:${error.message}`);
+      console.error('Hint error:', error);
+    } finally {
+      this.setControlsDisabled(false);
+    }
+  }
+
+  // Submit guess
+  async submitGuess() {
+    const guessInput = document.getElementById('guess-input');
+    const guess = guessInput.value.trim();
+
+    if (!guess) {
+      alert('请输入猜测的人物姓名');
+      return;
+    }
+    if (!this.sessionId) return;
+
+    this.setControlsDisabled(true);
+
+    try {
+      const response = await fetch('http://localhost:8000/api/game/guess', {
+        method: 'POST',
+        headers: {'Content-Type': 'application/json'},
+        body: JSON.stringify({session_id: this.sessionId, guess: guess})
+      });
+
+      if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
+
+      const result = await response.json();
+
+      if (!result.success) throw new Error(result.error || '猜测失败');
+
+      const data = result.data;
+      guessInput.value = '';
+
+      if (data.is_correct) {
+        this.addMessage(`🎉 恭喜!你猜对了!答案就是:${guess}`, 'agent');
+        this.endGame(true, data.figure_info, data.portrait_images || []);
+      } else {
+        this.addMessage(`❌ 猜错了!${data.message || '再想想看~'}`, 'agent');
+        this.remainingQuestions = data.remaining_questions !== undefined ?
+            data.remaining_questions :
+            this.remainingQuestions - 1;
+        this.updateStats();
+
+        if (data.is_game_over || this.remainingQuestions <= 0) {
+          this.endGame(false);
+        }
+      }
+
+    } catch (error) {
+      alert(`提交猜测失败:${error.message}`);
+      console.error('Guess error:', error);
+    } finally {
+      this.setControlsDisabled(false);
+    }
+  }
+
+  // End game and show result
+  async endGame(isWin, figureInfo = null, portraitImages = []) {
+    // Disable controls
+    document.getElementById('user-input').disabled = true;
+    document.getElementById('send-btn').disabled = true;
+    document.getElementById('get-hint').disabled = true;
+    document.getElementById('guess-btn').disabled = true;
+    document.getElementById('guess-input').disabled = true;
+
+    // Fetch figure info if not provided
+    if (!figureInfo && this.sessionId) {
+      try {
+        const response = await fetch('http://localhost:8000/api/game/end', {
+          method: 'POST',
+          headers: {'Content-Type': 'application/json'},
+          body: JSON.stringify({session_id: this.sessionId})
+        });
+        if (response.ok) {
+          const result = await response.json();
+          if (result.success && result.data) {
+            figureInfo = result.data;
+          }
+        }
+      } catch (e) {
+        console.error('End game fetch error:', e);
+      }
+    }
+
+    // Show result modal
+    document.getElementById('result-modal').classList.remove('hidden');
+
+    const resultTitle = document.getElementById('result-title');
+    const resultMessage = document.getElementById('result-message');
+    const figureInfoEl = document.getElementById('figure-info');
+
+    if (isWin) {
+      resultTitle.textContent = '🎉 恭喜你猜对了!';
+      resultMessage.textContent = '你成功猜出了这个人物的身份!';
+    } else {
+      resultTitle.textContent = '⏰ 游戏结束';
+      resultMessage.textContent = '提问次数已用完,下次加油!';
+    }
+
+    if (figureInfo) {
+      const name = figureInfo.figure_name || figureInfo.name || '未知';
+      const dynasty = figureInfo.dynasty || '';
+      const occupation = figureInfo.occupation || figureInfo.profession || '';
+      const achievements = figureInfo.achievements || '';
+      const characteristics =
+          figureInfo.characteristics || figureInfo.key_features || '';
+
+      // Build portrait gallery HTML
+      let portraitHtml = '';
+      if (portraitImages && portraitImages.length > 0) {
+        const imgItems = portraitImages
+                             .map(photo => `
+          <div class="portrait-item">
+            <img src="${photo.url}" alt="${photo.description || name}"
+                 title="📷 ${photo.photographer || 'Unsplash'}"
+                 onerror="this.parentElement.style.display='none'">
+          </div>
+        `).join('');
+        portraitHtml = `<div class="portrait-gallery">${imgItems}</div>`;
+      }
+
+      figureInfoEl.innerHTML = `
+        ${portraitHtml}
+        <p><strong>答案:</strong>${name}</p>
+        ${dynasty ? `<p><strong>朝代/时代:</strong>${dynasty}</p>` : ''}
+        ${occupation ? `<p><strong>职业/身份:</strong>${occupation}</p>` : ''}
+        ${
+          achievements ? `<p><strong>主要成就:</strong>${achievements}</p>` :
+                         ''}
+        ${
+          characteristics ?
+              `<p><strong>关键特征:</strong>${characteristics}</p>` :
+              ''}
+      `;
+    } else {
+      figureInfoEl.innerHTML = '';
+    }
+
+    this.sessionId = null;
+  }
+
+  // Restart game
+  restartGame() {
+    document.getElementById('result-modal').classList.add('hidden');
+    document.getElementById('game-section').classList.add('hidden');
+    document.getElementById('intro-section').classList.remove('hidden');
+    this.clearChat();
+    this.sessionId = null;
+    this.remainingQuestions = 20;
+    this.remainingHints = 3;
+  }
+
+  // Add message to chat
+  addMessage(text, type) {
+    const chatContainer = document.getElementById('chat-container');
+
+    // Remove static welcome message on first real message
+    const staticWelcome = chatContainer.querySelector('.welcome-message');
+    if (staticWelcome) staticWelcome.remove();
+
+    const messageDiv = document.createElement('div');
+    messageDiv.className = `message ${type}-message`;
+
+    const contentDiv = document.createElement('div');
+    contentDiv.className = 'message-content';
+    contentDiv.textContent = text;
+
+    messageDiv.appendChild(contentDiv);
+    chatContainer.appendChild(messageDiv);
+    chatContainer.scrollTop = chatContainer.scrollHeight;
+  }
+
+  // Clear chat
+  clearChat() {
+    const chatContainer = document.getElementById('chat-container');
+    chatContainer.innerHTML = `
+      <div class="welcome-message">
+        <div class="message agent-message">
+          <div class="message-content">
+            你好!我是一个知名人物,你可以通过提问来猜测我的身份。开始吧!
+          </div>
+        </div>
+      </div>`;
+  }
+
+  // Update stats display
+  updateStats() {
+    document.getElementById('remaining-questions').textContent =
+        `剩余提问: ${this.remainingQuestions}`;
+    document.getElementById('remaining-hints').textContent =
+        `剩余提示: ${this.remainingHints}`;
+  }
+
+  // Disable/enable game controls
+  setControlsDisabled(disabled) {
+    document.getElementById('send-btn').disabled = disabled;
+    document.getElementById('get-hint').disabled = disabled;
+    document.getElementById('guess-btn').disabled = disabled;
+    document.getElementById('user-input').disabled = disabled;
+    document.getElementById('guess-input').disabled = disabled;
+  }
+
+  // Start button loading state
+  setStartBtnLoading(loading) {
+    const btn = document.getElementById('start-game');
+    btn.disabled = loading;
+    btn.textContent = loading ? '正在启动...' : '开始游戏';
+  }
+
+  // Show full-screen loading overlay with step text rotation
+  showLoadingOverlay() {
+    const overlay = document.getElementById('loading-overlay');
+    const stepEl = document.getElementById('loading-step');
+    overlay.classList.remove('hidden');
+
+    const steps = [
+      '🔍 正在随机选择人物...',
+      '📚 正在搜索人物资料...',
+      '🤖 AI 正在准备提示...',
+      '🎤 正在准备角色扮演...',
+    ];
+    let idx = 0;
+    stepEl.textContent = steps[0];
+
+    this._loadingTimer = setInterval(() => {
+      idx = (idx + 1) % steps.length;
+      stepEl.style.opacity = '0';
+      setTimeout(() => {
+        stepEl.textContent = steps[idx];
+        stepEl.style.opacity = '1';
+      }, 400);
+    }, 2000);
+  }
+
+  // Hide loading overlay and clear timer
+  hideLoadingOverlay() {
+    const overlay = document.getElementById('loading-overlay');
+    overlay.classList.add('hidden');
+    if (this._loadingTimer) {
+      clearInterval(this._loadingTimer);
+      this._loadingTimer = null;
+    }
+  }
+}
+
+// Initialize game on page load
+window.onload = function() {
+  window.game = new GuessWhoAmIGame();
+};

+ 79 - 0
Co-creation-projects/afei-GuessWhoAmI/frontend/index.html

@@ -0,0 +1,79 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>GuessWhoAmI - 智能对话游戏</title>
+    <link rel="stylesheet" href="style.css?v=3">
+</head>
+<body>
+    <div class="container">
+        <header>
+            <h1>🎤 GuessWhoAmI</h1>
+            <p class="subtitle">与AI智能对话,猜出神秘人物</p>        </header>
+
+        <div id="intro-section" class="intro-section">
+            <div class="game-rules">
+                <h2>游戏规则</h2>
+                <ul>
+                    <li>🎯 系统会随机选择一个知名人物</li>
+                    <li>💬 你可以通过提问来获取线索</li>
+                    <li>🤔 尝试猜出这个人物的名字</li>
+                    <li>📝 每局最多提问10次,可使用 3 次提示</li>
+                    <li>🏆 看看你能在多短时间内猜对!</li>
+                </ul>            </div>
+            
+            <button id="start-game" class="start-btn">开始游戏</button>
+        </div>
+
+        <!-- Loading overlay shown during Tavily search + LLM distillation -->
+        <div id="loading-overlay" class="loading-overlay hidden">
+            <div class="loading-box">
+                <div class="loading-spinner"></div>
+                <p class="loading-title">正在准备游戏...</p>
+                <p id="loading-step" class="loading-step">🔍 正在搜索历史人物资料</p>
+            </div>
+        </div>
+
+        <div id="game-section" class="game-section hidden">
+            <div class="game-header">
+                <div class="game-stats">
+                    <span id="remaining-questions">剩余提问: 20</span>
+                    <span id="remaining-hints">剩余提示: 3</span>
+                </div>
+                <button id="get-hint" class="hint-btn" disabled>获取提示</button>
+            </div>
+
+            <div id="chat-container" class="chat-container">
+                <div class="welcome-message">
+                    <div class="message agent-message">
+                        <div class="message-content">
+                            你好!我是一个知名人物,你可以通过提问来猜测我的身份。开始吧!
+                        </div>                    </div>
+                </div>
+            </div>
+
+            <div class="input-container">
+                <input type="text" id="user-input" placeholder="输入你的问题或猜测..." disabled>
+                <button id="send-btn" disabled>发送</button>
+            </div>
+
+            <div class="guess-section">
+            <input type="text" id="guess-input" placeholder="直接猜测人物名字..." disabled>
+                <button id="guess-btn" disabled>猜一下</button>
+            </div>
+        </div>
+
+        <div id="result-modal" class="modal hidden">
+            <div class="modal-content">
+                <h2 id="result-title"></h2>
+                <p id="result-message"></p>
+                <div id="figure-info"></div>
+                <button id="play-again">再来一局</button>
+            </div>
+        </div>
+    </div>
+
+    <script src="app.js?v=3"></script>
+</body>
+</html>

+ 405 - 0
Co-creation-projects/afei-GuessWhoAmI/frontend/style.css

@@ -0,0 +1,405 @@
+/* 全局样式 */
+* {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+}
+
+body {
+    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    min-height: 100vh;
+    color: #333;
+}
+
+.container {
+    max-width: 800px;
+    margin: 0 auto;
+    padding: 20px;
+    min-height: 100vh;
+}
+
+/* 头部样式 */
+header {
+    text-align: center;
+    margin-bottom: 30px;
+    color: white;
+}
+
+header h1 {
+    font-size: 2.5rem;
+    margin-bottom: 10px;
+    text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
+}
+
+.subtitle {
+    font-size: 1.1rem;
+    opacity: 0.9;
+}
+
+/* 介绍区域 */
+.intro-section {
+    background: white;
+    border-radius: 15px;
+    padding: 30px;
+    text-align: center;
+    box-shadow: 0 10px 30px rgba(0,0,0,0.2);
+}
+
+.game-rules {
+    margin-bottom: 30px;
+}
+
+.game-rules h2 {
+    color: #667eea;
+    margin-bottom: 20px;
+}
+
+.game-rules ul {
+    list-style: none;
+    text-align: left;
+    max-width: 400px;
+    margin: 0 auto;
+}
+
+.game-rules li {
+    padding: 8px 0;
+    border-bottom: 1px solid #eee;
+}
+
+/* 按钮样式 */
+.start-btn, .hint-btn {
+    background: linear-gradient(135deg, #667eea, #764ba2);
+    color: white;
+    border: none;
+    padding: 15px 30px;
+    border-radius: 25px;
+    font-size: 1.1rem;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    font-weight: bold;
+}
+
+.start-btn:hover, .hint-btn:hover {
+    transform: translateY(-2px);
+    box-shadow: 0 5px 15px rgba(0,0,0,0.3);
+}
+
+.start-btn:disabled, .hint-btn:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+    transform: none;
+}
+
+/* 游戏区域 */
+.game-section {
+    background: white;
+    border-radius: 15px;
+    padding: 20px;
+    box-shadow: 0 10px 30px rgba(0,0,0,0.2);
+    display: flex;
+    flex-direction: column;
+    height: 600px;
+}
+
+.game-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 20px;
+    padding-bottom: 15px;
+    border-bottom: 2px solid #f0f0f0;
+}
+
+.game-stats {
+    display: flex;
+    gap: 20px;
+    font-weight: bold;
+    color: #667eea;
+}
+
+.hint-btn {
+    padding: 8px 16px;
+    font-size: 0.9rem;
+}
+
+/* 聊天容器 */
+.chat-container {
+    flex: 1;
+    overflow-y: auto;
+    padding: 10px;
+    background: #f8f9fa;
+    border-radius: 10px;
+    margin-bottom: 15px;
+}
+
+.welcome-message {
+    margin-bottom: 15px;
+}
+
+/* 消息样式 */
+.message {
+    display: flex;
+    margin-bottom: 15px;
+    animation: fadeIn 0.3s ease;
+}
+
+.message-content {
+    max-width: 70%;
+    padding: 12px 16px;
+    border-radius: 18px;
+    line-height: 1.4;
+}
+
+.user-message {
+    justify-content: flex-end;
+}
+
+.user-message .message-content {
+    background: linear-gradient(135deg, #667eea, #764ba2);
+    color: white;
+    border-bottom-right-radius: 5px;
+}
+
+.agent-message {
+    justify-content: flex-start;
+}
+
+.agent-message .message-content {
+    background: #e3f2fd;
+    color: #333;
+    border-bottom-left-radius: 5px;
+}
+
+/* 输入区域 */
+.input-container, .guess-section {
+    display: flex;
+    gap: 10px;
+    margin-bottom: 10px;
+}
+
+.input-container input, .guess-section input {
+    flex: 1;
+    padding: 12px 16px;
+    border: 2px solid #ddd;
+    border-radius: 25px;
+    font-size: 1rem;
+    outline: none;
+    transition: border-color 0.3s ease;
+}
+
+.input-container input:focus, .guess-section input:focus {
+    border-color: #667eea;
+}
+
+.input-container button, .guess-section button {
+    padding: 12px 20px;
+    background: #667eea;
+    color: white;
+    border: none;
+    border-radius: 25px;
+    cursor: pointer;
+    font-weight: bold;
+    transition: background 0.3s ease;
+}
+
+.input-container button:hover, .guess-section button:hover {
+    background: #5a6fd8;
+}
+
+.input-container button:disabled, .guess-section button:disabled {
+    background: #ccc;
+    cursor: not-allowed;
+}
+
+.guess-section {
+    background: #fff3cd;
+    padding: 15px;
+    border-radius: 10px;
+    border: 2px solid #ffeaa7;
+}
+
+.guess-section input {
+    background: #fff;
+}
+
+/* 模态框 */
+.modal {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(0,0,0,0.7);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    z-index: 1000;
+}
+
+.modal-content {
+    background: white;
+    padding: 30px;
+    border-radius: 15px;
+    text-align: center;
+    max-width: 500px;
+    width: 90%;
+}
+
+.modal-content h2 {
+    color: #667eea;
+    margin-bottom: 20px;
+}
+
+.modal-content p {
+    margin-bottom: 20px;
+    line-height: 1.6;
+}
+
+#figure-info {
+    background: #f8f9fa;
+    padding: 15px;
+    border-radius: 10px;
+    margin-bottom: 20px;
+    text-align: left;
+}
+
+#figure-info h3 {
+    color: #667eea;
+    margin-bottom: 10px;
+}
+
+/* Portrait gallery */
+.portrait-gallery {
+    display: flex;
+    gap: 8px;
+    justify-content: center;
+    margin-bottom: 14px;
+    flex-wrap: wrap;
+}
+
+.portrait-item {
+    flex: 0 0 auto;
+    width: 120px;
+    height: 120px;
+    border-radius: 10px;
+    overflow: hidden;
+    box-shadow: 0 3px 10px rgba(0,0,0,0.15);
+    border: 2px solid #e0e0e0;
+    transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.portrait-item:hover {
+    transform: scale(1.05);
+    box-shadow: 0 6px 18px rgba(102,126,234,0.4);
+    border-color: #667eea;
+}
+
+.portrait-item img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+}
+
+/* 工具类 */
+.hidden {
+    display: none !important;
+}
+
+.loading {
+    opacity: 0.6;
+    pointer-events: none;
+}
+
+/* 动画 */
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+        transform: translateY(10px);
+    }
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+/* Loading overlay */
+.loading-overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(102, 126, 234, 0.85);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    z-index: 2000;
+    backdrop-filter: blur(4px);
+}
+
+.loading-box {
+    background: white;
+    border-radius: 20px;
+    padding: 40px 50px;
+    text-align: center;
+    box-shadow: 0 20px 60px rgba(0,0,0,0.3);
+    animation: fadeIn 0.3s ease;
+}
+
+.loading-spinner {
+    width: 56px;
+    height: 56px;
+    border: 5px solid #e0e0e0;
+    border-top-color: #667eea;
+    border-radius: 50%;
+    animation: spin 0.9s linear infinite;
+    margin: 0 auto 20px;
+}
+
+.loading-title {
+    font-size: 1.2rem;
+    font-weight: bold;
+    color: #333;
+    margin-bottom: 10px;
+}
+
+.loading-step {
+    font-size: 0.95rem;
+    color: #667eea;
+    min-height: 1.4em;
+    transition: opacity 0.4s ease;
+}
+
+@keyframes spin {
+    to { transform: rotate(360deg); }
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+    .container {
+        padding: 10px;
+    }
+    
+    header h1 {
+        font-size: 2rem;
+    }
+    
+    .game-section {
+        height: 500px;
+    }
+    
+    .message-content {
+        max-width: 85%;
+    }
+    
+    .input-container, .guess-section {
+        flex-direction: column;
+    }
+    
+    .game-stats {
+        flex-direction: column;
+        gap: 5px;
+    }
+}

+ 125 - 0
Co-creation-projects/afei-GuessWhoAmI/restart.sh

@@ -0,0 +1,125 @@
+#!/bin/bash
+# ============================================================
+# Restart script for GuessWhoAmI
+# Backend:  FastAPI on port 8000
+# Frontend: Python http.server on port 3000
+# ============================================================
+
+set -e
+
+PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
+BACKEND_DIR="$PROJECT_DIR/backend"
+FRONTEND_DIR="$PROJECT_DIR/frontend"
+# python解释器路径
+VENV_PYTHON="/home/afei/hello_agent_venv/bin/python"
+
+BACKEND_PORT=8000
+FRONTEND_PORT=3000
+
+LOG_DIR="$PROJECT_DIR/logs"
+mkdir -p "$LOG_DIR"
+
+BACKEND_LOG="$LOG_DIR/backend.log"
+FRONTEND_LOG="$LOG_DIR/frontend.log"
+
+# ── Color helpers ──────────────────────────────────────────
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m'
+
+info()    { echo -e "${GREEN}[INFO]${NC}  $*"; }
+warn()    { echo -e "${YELLOW}[WARN]${NC}  $*"; }
+error()   { echo -e "${RED}[ERROR]${NC} $*"; }
+
+# ── Kill processes on a given port ─────────────────────────
+kill_port() {
+    local port=$1
+    local pids
+    pids=$(lsof -ti tcp:"$port" 2>/dev/null || true)
+    if [ -n "$pids" ]; then
+        echo "$pids" | xargs kill -9 2>/dev/null || true
+        warn "Killed existing process(es) on port $port: $pids"
+    fi
+}
+
+# ── Also kill by process name pattern ──────────────────────
+kill_pattern() {
+    local pattern=$1
+    local pids
+    pids=$(pgrep -f "$pattern" 2>/dev/null || true)
+    if [ -n "$pids" ]; then
+        echo "$pids" | xargs kill -9 2>/dev/null || true
+        warn "Killed process(es) matching '$pattern': $pids"
+    fi
+}
+
+# ── Wait for a port to become available ────────────────────
+wait_for_port() {
+    local port=$1
+    local name=$2
+    local max_wait=15
+    local count=0
+    while ! lsof -ti tcp:"$port" >/dev/null 2>&1; do
+        sleep 1
+        count=$((count + 1))
+        if [ "$count" -ge "$max_wait" ]; then
+            error "$name failed to start on port $port within ${max_wait}s"
+            error "Check log: $LOG_DIR/${name,,}.log"
+            exit 1
+        fi
+    done
+    info "$name is up on port $port ✓"
+}
+
+# ══════════════════════════════════════════════════════════
+echo ""
+echo "╔══════════════════════════════════════════════╗"
+echo "║      GuessWhoAmI — Restart Script           ║"
+echo "╚══════════════════════════════════════════════╝"
+echo ""
+
+# ── Step 1: Stop existing services ────────────────────────
+info "Stopping existing services..."
+kill_port "$BACKEND_PORT"
+kill_port "$FRONTEND_PORT"
+kill_pattern "main.py"
+kill_pattern "GuessWhoAmI/frontend"
+sleep 1
+
+# ── Step 2: Start backend ──────────────────────────────────
+info "Starting backend (port $BACKEND_PORT)..."
+# backend.log is managed by Python's FileHandler; stdout/stderr go to /dev/null
+cd "$BACKEND_DIR"
+nohup "$VENV_PYTHON" main.py > /dev/null 2>&1 &
+BACKEND_PID=$!
+info "Backend PID: $BACKEND_PID"
+
+wait_for_port "$BACKEND_PORT" "Backend"
+
+# ── Step 3: Start frontend ─────────────────────────────────
+info "Starting frontend (port $FRONTEND_PORT)..."
+> "$FRONTEND_LOG"  # clear log on each restart
+cd "$FRONTEND_DIR"
+nohup "$VENV_PYTHON" -m http.server "$FRONTEND_PORT" > "$FRONTEND_LOG" 2>&1 &
+FRONTEND_PID=$!
+info "Frontend PID: $FRONTEND_PID"
+
+wait_for_port "$FRONTEND_PORT" "Frontend"
+
+# ── Done ───────────────────────────────────────────────────
+echo ""
+echo -e "${GREEN}✅ All services started successfully!${NC}"
+echo ""
+echo "  🔧 Backend  → http://localhost:$BACKEND_PORT"
+echo "  🔧 API Docs → http://localhost:$BACKEND_PORT/docs"
+echo "  🌐 Frontend → http://localhost:$FRONTEND_PORT"
+echo ""
+echo "  📄 Logs:"
+echo "     Backend  : $BACKEND_LOG"
+echo "     Frontend : $FRONTEND_LOG"
+echo ""
+echo "  To stop all services:"
+echo "     kill $BACKEND_PID $FRONTEND_PID"
+echo "  Or run:  bash $PROJECT_DIR/stop.sh"
+echo ""