From d47ccd7039972d57d5d9dec941f0eb3219f28ae5 Mon Sep 17 00:00:00 2001 From: liangguodong Date: Thu, 5 Mar 2026 15:57:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E8=A3=85AI=E6=94=B9=E5=86=99?= =?UTF-8?q?=E7=BB=93=E5=B1=80=E5=8A=9F=E8=83=BD=20-=20=E6=8E=A5=E5=85=A5De?= =?UTF-8?q?epSeek=20API=20-=20AI=E5=8A=A8=E6=80=81=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=96=B0=E7=BB=93=E5=B1=80=E5=90=8D=E7=A7=B0=20-=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9Erewrite=E7=B1=BB=E5=9E=8B=E7=BB=93=E5=B1=80=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=20-=20=E4=BF=AE=E5=A4=8D=E8=AF=B7=E6=B1=82=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/js/data/StoryManager.js | 4 +- client/js/scenes/EndingScene.js | 8 ++ client/js/scenes/StoryScene.js | 15 +++ client/js/utils/http.js | 13 +- server/.env.example | 2 +- server/AI_CONFIG.md | 150 +++++++++++++++++++++++ server/app/config.py | 15 ++- server/app/routers/story.py | 55 ++++++++- server/app/services/ai.py | 211 ++++++++++++++++++++++++++++++++ server/init_db.py | 40 ++++++ server/test_db.py | 15 +++ 11 files changed, 513 insertions(+), 15 deletions(-) create mode 100644 server/AI_CONFIG.md create mode 100644 server/app/services/ai.py create mode 100644 server/init_db.py create mode 100644 server/test_db.py diff --git a/client/js/data/StoryManager.js b/client/js/data/StoryManager.js index 204377d..81c8854 100644 --- a/client/js/data/StoryManager.js +++ b/client/js/data/StoryManager.js @@ -137,10 +137,10 @@ export default class StoryManager { ending_name: ending?.name, ending_content: ending?.content, prompt: prompt - }); + }, { timeout: 60000 }); return result; } catch (error) { - console.error('AI改写失败:', error); + console.error('AI改写失败:', error?.errMsg || error?.message || JSON.stringify(error)); return null; } } diff --git a/client/js/scenes/EndingScene.js b/client/js/scenes/EndingScene.js index 3f985de..99bad3a 100644 --- a/client/js/scenes/EndingScene.js +++ b/client/js/scenes/EndingScene.js @@ -8,6 +8,7 @@ export default class EndingScene extends BaseScene { super(main, params); this.storyId = params.storyId; this.ending = params.ending; + console.log('EndingScene 接收到的结局:', JSON.stringify(this.ending)); this.showButtons = false; this.fadeIn = 0; this.particles = []; @@ -103,6 +104,11 @@ export default class EndingScene extends BaseScene { gradient.addColorStop(0.5, '#3a3515'); gradient.addColorStop(1, '#2d2d1f'); break; + case 'rewrite': + gradient.addColorStop(0, '#1a0a2e'); + gradient.addColorStop(0.5, '#2d1b4e'); + gradient.addColorStop(1, '#4a1942'); + break; default: gradient.addColorStop(0, '#0f0c29'); gradient.addColorStop(0.5, '#302b63'); @@ -195,6 +201,7 @@ export default class EndingScene extends BaseScene { case 'good': return '✨ 完美结局'; case 'bad': return '💔 悲伤结局'; case 'hidden': return '🔮 隐藏结局'; + case 'rewrite': return '🤖 AI改写结局'; default: return '📖 普通结局'; } } @@ -239,6 +246,7 @@ export default class EndingScene extends BaseScene { case 'good': return `rgba(100, 255, 150, ${alpha})`; case 'bad': return `rgba(255, 100, 100, ${alpha})`; case 'hidden': return `rgba(255, 215, 0, ${alpha})`; + case 'rewrite': return `rgba(168, 85, 247, ${alpha})`; default: return `rgba(150, 150, 255, ${alpha})`; } } diff --git a/client/js/scenes/StoryScene.js b/client/js/scenes/StoryScene.js index e9249d6..b82d0b4 100644 --- a/client/js/scenes/StoryScene.js +++ b/client/js/scenes/StoryScene.js @@ -538,6 +538,21 @@ export default class StoryScene extends BaseScene { if (this.waitingForClick) { this.waitingForClick = false; + // AI改写内容 - 直接跳转到新结局 + if (this.aiContent && this.aiContent.is_ending) { + console.log('AI改写内容:', JSON.stringify(this.aiContent)); + this.main.sceneManager.switchScene('ending', { + storyId: this.storyId, + ending: { + name: this.aiContent.ending_name, + type: this.aiContent.ending_type, + content: this.aiContent.content, + score: 100 + } + }); + return; + } + // 检查是否是结局 if (this.main.storyManager.isEnding()) { this.main.sceneManager.switchScene('ending', { diff --git a/client/js/utils/http.js b/client/js/utils/http.js index a1f9a02..80adb34 100644 --- a/client/js/utils/http.js +++ b/client/js/utils/http.js @@ -10,20 +10,18 @@ const BASE_URL = 'http://localhost:3000/api'; */ export function request(options) { return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('请求超时')); - }, 5000); - + const timeoutMs = options.timeout || 30000; + wx.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, + timeout: timeoutMs, header: { 'Content-Type': 'application/json', ...options.header }, success(res) { - clearTimeout(timeout); if (res.data.code === 0) { resolve(res.data.data); } else { @@ -31,7 +29,6 @@ export function request(options) { } }, fail(err) { - clearTimeout(timeout); reject(err); } }); @@ -48,8 +45,8 @@ export function get(url, data) { /** * POST请求 */ -export function post(url, data) { - return request({ url, method: 'POST', data }); +export function post(url, data, options = {}) { + return request({ url, method: 'POST', data, ...options }); } export default { request, get, post }; diff --git a/server/.env.example b/server/.env.example index 1fda328..5dad497 100644 --- a/server/.env.example +++ b/server/.env.example @@ -5,7 +5,7 @@ PORT=3000 DB_HOST=localhost DB_PORT=3306 DB_USER=root -DB_PASSWORD=your_password +DB_PASSWORD=liang20020523 DB_NAME=stardom_story # 微信小游戏配置 diff --git a/server/AI_CONFIG.md b/server/AI_CONFIG.md new file mode 100644 index 0000000..e219bbb --- /dev/null +++ b/server/AI_CONFIG.md @@ -0,0 +1,150 @@ +# AI服务配置指南 + +## 快速开始 + +### 1. 复制配置文件 +```bash +cd server +cp .env.example .env +``` + +### 2. 选择 AI服务商 + +目前支持 4 种 AI服务,**任选其一**即可: + +#### 方案 A:DeepSeek(推荐,性价比高) +```env +AI_SERVICE_ENABLED=true +AI_PROVIDER=deepseek +DEEPSEEK_API_KEY=sk-a685e8a0e97e41e4b3cb70fa6fcc3af1 +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +DEEPSEEK_MODEL=deepseek-chat +``` + +#### 方案 B:通义千问(国内访问快) +```env +AI_SERVICE_ENABLED=true +AI_PROVIDER=qwen +DASHSCOPE_API_KEY=你的 dashscope API Key +QWEN_MODEL=qwen-plus +``` + +#### 方案 C:OpenAI(国际版) +```env +AI_SERVICE_ENABLED=true +AI_PROVIDER=openai +OPENAI_API_KEY=sk-your-openai-key +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-3.5-turbo +``` + +#### 方案 D:Claude(高质量) +```env +AI_SERVICE_ENABLED=true +AI_PROVIDER=claude +CLAUDE_API_KEY=你的 claude API Key +CLAUDE_MODEL=claude-3-haiku-20240307 +``` + +### 3. 安装依赖 +```bash +pip install -r requirements.txt +``` + +### 4. 启动服务 +```bash +python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 3000 +``` + +## DeepSeek 详细配置 + +### API Key 获取 +1. 访问 https://platform.deepseek.com/ +2. 注册/登录账号 +3. 进入控制台 → API Keys +4. 创建新的 API Key +5. 复制到 `.env` 文件的 `DEEPSEEK_API_KEY` + +### 计费说明 +- **价格**: 输入¥0.002/1K tokens,输出¥0.002/1K tokens(截至 2024 年) +- **免费额度**: 新用户注册赠送¥14 元体验金 +- **充值**: 最低充值¥10 元起 + +### 限流说明 +- **QPS**: 默认 3 次/秒 +- **RPM**: 默认 60 次/分钟 +- **TPM**: 默认 200K tokens/分钟 + +如需提升限额,联系官方客服。 + +## 测试 AI 功能 + +### 方法 1:通过小游戏界面 +1. 启动后端服务 +2. 打开微信开发者工具 +3. 进入任意故事的结局页 +4. 点击"✨ AI改写结局" +5. 选择标签或输入自定义指令 +6. 点击"✨ 开始改写" + +### 方法 2:直接调用 API +```bash +curl -X POST http://localhost:3000/api/stories/1/rewrite \ + -H "Content-Type: application/json" \ + -d '{ + "ending_name": "双向奔赴", + "ending_content": "原结局内容...", + "prompt": "让主角逆袭成功" + }' +``` + +## 常见问题 + +### Q: AI_SERVICE_ENABLED=false 会怎样? +A: 将使用模拟数据(随机模板),不会调用真实AI API,不产生费用。 + +### Q: 可以混合使用多个 AI服务吗? +A: 不建议。每次只能选择一个提供商。如需切换,修改 `AI_PROVIDER` 后重启服务。 + +### Q: 调用失败怎么办? +A: 系统会自动降级到模拟模式,保证用户体验不受影响。 + +### Q: 如何查看 Token 消耗? +A: API 返回的 `tokens_used` 字段包含本次消耗的 token 数量。可在日志中查看。 + +## 安全建议 + +1. **不要提交`.env` 文件到 Git** + ```bash + echo ".env" >> .gitignore + ``` + +2. **定期轮换 API Key** + - 每 3 个月更换一次 + - 发现异常立即更换 + +3. **设置预算告警** + - 在 AI 平台设置每月消费上限 + - 开启短信/邮件通知 + +## 性能优化 + +### 降低延迟 +- 选择地理位置近的 API端点 +- 使用 CDN 加速(如 Cloudflare) +- 启用连接池复用 + +### 降低成本 +- 调整 `temperature` 参数(0.7-0.9 之间) +- 限制 `max_tokens`(300-500 足够) +- 缓存相似请求的结果 + +## 监控指标 + +建议监控以下指标: +- AI 调用成功率 +- 平均响应时间 +- Token 消耗速率 +- 每日调用次数 + +可通过 Prometheus + Grafana 搭建监控系统。 diff --git a/server/app/config.py b/server/app/config.py index ddae645..752c75f 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -19,10 +19,23 @@ class Settings(BaseSettings): server_port: int = 3000 debug: bool = True - # AI服务配置(预留) + # AI 服务配置 + ai_service_enabled: bool = True + ai_provider: str = "deepseek" + + # DeepSeek 配置 + deepseek_api_key: str = "" + deepseek_base_url: str = "https://api.deepseek.com/v1" + deepseek_model: str = "deepseek-chat" + + # OpenAI 配置(备用) openai_api_key: str = "" openai_base_url: str = "https://api.openai.com/v1" + # 微信小游戏配置(预留) + wx_appid: str = "" + wx_secret: str = "" + @property def database_url(self) -> str: return f"mysql+aiomysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" diff --git a/server/app/routers/story.py b/server/app/routers/story.py index 9af1056..fb122fe 100644 --- a/server/app/routers/story.py +++ b/server/app/routers/story.py @@ -187,6 +187,9 @@ async def toggle_like(story_id: int, request: LikeRequest, db: AsyncSession = De @router.post("/{story_id}/rewrite") async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSession = Depends(get_db)): """AI改写结局""" + import json + import re + if not request.prompt: raise HTTPException(status_code=400, detail="请输入改写指令") @@ -194,7 +197,54 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes result = await db.execute(select(Story).where(Story.id == story_id)) story = result.scalar_one_or_none() - # 模拟AI生成(后续替换为真实API调用) + if not story: + raise HTTPException(status_code=404, detail="故事不存在") + + # 调用 AI 服务 + from app.services.ai import ai_service + + ai_result = await ai_service.rewrite_ending( + story_title=story.title, + story_category=story.category or "未知", + ending_name=request.ending_name or "未知结局", + ending_content=request.ending_content or "", + user_prompt=request.prompt + ) + + if ai_result and ai_result.get("content"): + content = ai_result["content"] + ending_name = f"{request.ending_name}(AI改写)" + + # 尝试解析 JSON 格式的返回 + try: + # 提取 JSON 部分 + json_match = re.search(r'\{[^{}]*"ending_name"[^{}]*"content"[^{}]*\}', content, re.DOTALL) + if json_match: + parsed = json.loads(json_match.group()) + ending_name = parsed.get("ending_name", ending_name) + content = parsed.get("content", content) + else: + # 尝试直接解析整个内容 + parsed = json.loads(content) + ending_name = parsed.get("ending_name", ending_name) + content = parsed.get("content", content) + except (json.JSONDecodeError, AttributeError): + # 解析失败,使用原始内容 + pass + + return { + "code": 0, + "data": { + "content": content, + "speaker": "旁白", + "is_ending": True, + "ending_name": ending_name, + "ending_type": "rewrite", + "tokens_used": ai_result.get("tokens_used", 0) + } + } + + # AI 服务不可用时的降级处理 templates = [ f"根据你的愿望「{request.prompt}」,故事有了新的发展...\n\n", f"命运的齿轮开始转动,{request.prompt}...\n\n", @@ -205,8 +255,7 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes new_content = ( template + "原本的结局被改写,新的故事在这里展开。\n\n" + - f"【AI改写提示】这是基于「{request.prompt}」生成的新结局。\n" + - "实际部署时,这里将由AI大模型根据上下文生成更精彩的内容。" + f"【提示】AI服务暂时不可用,这是模板内容。" ) return { diff --git a/server/app/services/ai.py b/server/app/services/ai.py new file mode 100644 index 0000000..e14df4f --- /dev/null +++ b/server/app/services/ai.py @@ -0,0 +1,211 @@ +""" +AI服务封装模块 +支持多种AI提供商:DeepSeek, OpenAI, Claude, 通义千问 +""" +from typing import Optional, Dict, Any +import httpx + +class AIService: + def __init__(self): + from app.config import get_settings + settings = get_settings() + + self.enabled = settings.ai_service_enabled + self.provider = settings.ai_provider + + # 根据提供商初始化配置 + if self.provider == "deepseek": + self.api_key = settings.deepseek_api_key + self.base_url = settings.deepseek_base_url + self.model = settings.deepseek_model + elif self.provider == "openai": + self.api_key = settings.openai_api_key + self.base_url = settings.openai_base_url + self.model = "gpt-3.5-turbo" + elif self.provider == "claude": + # Claude 需要从环境变量读取 + import os + self.api_key = os.getenv("CLAUDE_API_KEY", "") + self.base_url = "https://api.anthropic.com" + self.model = "claude-3-haiku-20240307" + elif self.provider == "qwen": + import os + self.api_key = os.getenv("DASHSCOPE_API_KEY", "") + self.base_url = "https://dashscope.aliyuncs.com/api/v1" + self.model = "qwen-plus" + else: + self.api_key = None + self.base_url = None + self.model = None + + async def rewrite_ending( + self, + story_title: str, + story_category: str, + ending_name: str, + ending_content: str, + user_prompt: str + ) -> Optional[Dict[str, Any]]: + """ + AI改写结局 + :return: {"content": str, "tokens_used": int} 或 None + """ + if not self.enabled or not self.api_key: + return None + + # 构建Prompt + system_prompt = """你是一个专业的互动故事创作专家。根据用户的改写指令,重新创作故事结局。 +要求: +1. 保持原故事的世界观和人物性格 +2. 结局要有张力和情感冲击 +3. 结局内容字数控制在200-400字 +4. 为新结局取一个4-8字的新名字,体现改写后的剧情走向 +5. 输出格式必须是JSON:{"ending_name": "新结局名称", "content": "结局内容"}""" + + user_prompt_text = f"""故事标题:{story_title} +故事分类:{story_category} +原结局名称:{ending_name} +原结局内容:{ending_content[:500]} +--- +用户改写指令:{user_prompt} +--- +请创作新的结局(输出JSON格式):""" + + try: + if self.provider == "openai": + return await self._call_openai(system_prompt, user_prompt_text) + elif self.provider == "claude": + return await self._call_claude(user_prompt_text) + elif self.provider == "qwen": + return await self._call_qwen(system_prompt, user_prompt_text) + elif self.provider == "deepseek": + return await self._call_deepseek(system_prompt, user_prompt_text) + except Exception as e: + print(f"AI调用失败:{e}") + return None + + return None + + async def _call_openai(self, system_prompt: str, user_prompt: str) -> Optional[Dict]: + """调用OpenAI API""" + url = f"{self.base_url}/chat/completions" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + data = { + "model": self.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + "temperature": 0.8, + "max_tokens": 500 + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + result = response.json() + + content = result["choices"][0]["message"]["content"] + tokens = result["usage"]["total_tokens"] + + return {"content": content.strip(), "tokens_used": tokens} + + async def _call_claude(self, prompt: str) -> Optional[Dict]: + """调用Claude API""" + url = "https://api.anthropic.com/v1/messages" + headers = { + "x-api-key": self.api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json" + } + data = { + "model": self.model, + "max_tokens": 1024, + "messages": [{"role": "user", "content": prompt}] + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + result = response.json() + + content = result["content"][0]["text"] + tokens = result.get("usage", {}).get("output_tokens", 0) + + return {"content": content.strip(), "tokens_used": tokens} + + async def _call_qwen(self, system_prompt: str, user_prompt: str) -> Optional[Dict]: + """调用通义千问API""" + url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + data = { + "model": self.model, + "input": { + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + }, + "parameters": { + "result_format": "message", + "temperature": 0.8 + } + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + result = response.json() + + content = result["output"]["choices"][0]["message"]["content"] + tokens = result.get("usage", {}).get("total_tokens", 0) + + return {"content": content.strip(), "tokens_used": tokens} + + async def _call_deepseek(self, system_prompt: str, user_prompt: str) -> Optional[Dict]: + """调用 DeepSeek API""" + url = f"{self.base_url}/chat/completions" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + data = { + "model": self.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + "temperature": 0.8, + "max_tokens": 500 + } + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.post(url, headers=headers, json=data) + response.raise_for_status() + result = response.json() + + if "choices" in result and len(result["choices"]) > 0: + content = result["choices"][0]["message"]["content"] + tokens = result.get("usage", {}).get("total_tokens", 0) + + return {"content": content.strip(), "tokens_used": tokens} + else: + print(f"DeepSeek API 返回异常:{result}") + return None + except httpx.HTTPStatusError as e: + print(f"DeepSeek HTTP 错误:{e.response.status_code} - {e.response.text}") + return None + except Exception as e: + print(f"DeepSeek 调用失败:{e}") + return None + + +# 单例模式 +ai_service = AIService() diff --git a/server/init_db.py b/server/init_db.py new file mode 100644 index 0000000..6958cf9 --- /dev/null +++ b/server/init_db.py @@ -0,0 +1,40 @@ +import asyncio +import aiomysql + +async def init_db(): + # 连接 MySQL + conn = await aiomysql.connect( + host='localhost', + port=3306, + user='root', + password='liang20020523', + db='ai_game', + charset='utf8mb4' + ) + + try: + async with conn.cursor() as cursor: + # 读取 SQL 文件 + with open('sql/schema_v2.sql', 'r', encoding='utf-8') as f: + sql_content = f.read() + + # 分割 SQL 语句(按分号分隔) + statements = [s.strip() for s in sql_content.split(';') if s.strip()] + + # 执行每个语句 + for stmt in statements: + if stmt and not stmt.startswith('--'): + try: + await cursor.execute(stmt) + print(f"✓ 执行成功") + except Exception as e: + print(f"✗ 执行失败:{e}") + + await conn.commit() + print("\n数据库初始化完成!") + + finally: + await conn.close() + +if __name__ == '__main__': + asyncio.run(init_db()) diff --git a/server/test_db.py b/server/test_db.py new file mode 100644 index 0000000..114dbe3 --- /dev/null +++ b/server/test_db.py @@ -0,0 +1,15 @@ +import asyncio +from app.database import AsyncSessionLocal +from sqlalchemy import text + +async def test(): + try: + async with AsyncSessionLocal() as session: + result = await session.execute(text('SELECT 1')) + print("数据库连接成功:", result.scalar()) + except Exception as e: + print("数据库连接失败:", e) + import traceback + traceback.print_exc() + +asyncio.run(test())