From 048dfc27f52844ec924afcd09166b807bdf34c37 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 29 Sep 2026 18:16:35 +0800 Subject: [PATCH] =?UTF-8?q?AI=20=E7=94=9F=E6=88=90:=20=E5=9F=BA=E4=BA=8E?= =?UTF-8?q?=20Flask=20=E7=9A=84=20AI=20=E5=85=8D=E8=B4=B9=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E5=BA=94=E7=94=A8=EF=BC=8C=E5=90=8E=E7=AB=AF=E9=9B=86?= =?UTF-8?q?=E6=88=90=E8=B1=86=E5=8C=85=EF=BC=88Doubao=EF=BC=89=E5=92=8C=20?= =?UTF-8?q?DeepSeek=EF=BC=8C=E9=80=9A=E8=BF=87=20SSE=20=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E6=8E=A8=E9=80=81=E6=B6=88=E6=81=AF=EF=BC=8C=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E7=AE=80=E6=B4=81=E7=9A=84=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 +++ README.md | 86 +++++++++++++++++++++++++ app.py | 82 +++++++++++++++++++++++ requirements.txt | 3 + services/__init__.py | 1 + services/deepseek.py | 59 +++++++++++++++++ services/doubao.py | 61 ++++++++++++++++++ static/css/style.css | 150 +++++++++++++++++++++++++++++++++++++++++++ static/js/main.js | 119 ++++++++++++++++++++++++++++++++++ templates/index.html | 39 +++++++++++ 10 files changed, 609 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 services/__init__.py create mode 100644 services/deepseek.py create mode 100644 services/doubao.py create mode 100644 static/css/style.css create mode 100644 static/js/main.js create mode 100644 templates/index.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..98384cc --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# 豆包(火山方舟)配置 +DOUBAO_API_KEY=your_doubao_api_key +DOUBAO_MODEL=doubao-1-5-pro-32k-250115 +DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 + +# DeepSeek 配置 +DEEPSEEK_API_KEY=your_deepseek_api_key +DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_BASE_URL=https://api.deepseek.com diff --git a/README.md b/README.md new file mode 100644 index 0000000..eabbfc5 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# AI Free Chat + +一个基于 Flask 的免费 AI 对话应用,后端集成豆包(Doubao)和 DeepSeek 大模型,通过 Server-Sent Events (SSE) 实现消息实时推送,前端提供简洁美观的聊天界面。 + +## 功能特性 + +- 支持豆包(Doubao)和 DeepSeek 双模型切换 +- SSE 实时流式推送,打字机效果 +- 简洁美观的响应式聊天界面 +- 支持多轮对话上下文 +- 免费使用(需自行申请 API Key) + +## 目录结构 + +``` +ai-free-chat/ +├── app.py # Flask 主应用 +├── requirements.txt # Python 依赖 +├── .env.example # 环境变量示例 +├── README.md # 说明文档 +├── services/ +│ ├── __init__.py +│ ├── doubao.py # 豆包 API 封装 +│ └── deepseek.py # DeepSeek API 封装 +├── static/ +│ ├── css/style.css # 样式 +│ └── js/main.js # 前端逻辑 +└── templates/ + └── index.html # 聊天页面 +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +pip install -r requirements.txt +``` + +### 2. 配置环境变量 + +复制 `.env.example` 为 `.env`,填入你的 API Key: + +```bash +cp .env.example .env +``` + +```env +DOUBAO_API_KEY=你的豆包APIKey +DOUBAO_MODEL=doubao-1-5-pro-32k-250115 +DEEPSEEK_API_KEY=你的DeepSeekAPIKey +DEEPSEEK_MODEL=deepseek-chat +``` + +- 豆包 API Key 申请:https://console.volcengine.com/ark +- DeepSeek API Key 申请:https://platform.deepseek.com + +### 3. 启动服务 + +```bash +python app.py +``` + +打开浏览器访问 http://127.0.0.1:5000 + +## API 说明 + +### POST /api/chat + +请求体: + +```json +{ + "provider": "deepseek", + "messages": [ + {"role": "user", "content": "你好"} + ] +} +``` + +响应:`text/event-stream` 流式数据,每行 `data: {"content": "..."}`。 + +## 注意事项 + +- 本项目仅用于学习交流,请遵守各平台的使用条款。 +- API Key 请妥善保管,不要提交到公开仓库。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..148e3df --- /dev/null +++ b/app.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +""" +AI Free Chat - Flask 主应用 +提供聊天页面和 SSE 流式接口 +""" +import os +import json +from flask import Flask, render_template, request, Response, stream_with_context +from dotenv import load_dotenv + +from services.doubao import DoubaoService +from services.deepseek import DeepSeekService + +# 加载环境变量 +load_dotenv() + +app = Flask(__name__) + +# 初始化服务 +services = { + "doubao": DoubaoService(), + "deepseek": DeepSeekService(), +} + + +@app.route("/") +def index(): + """渲染聊天页面""" + return render_template("index.html") + + +@app.route("/api/chat", methods=["POST"]) +def chat(): + """ + SSE 流式聊天接口 + 请求体: { provider: 'doubao'|'deepseek', messages: [{role, content}] } + 响应: text/event-stream + """ + data = request.get_json(silent=True) or {} + provider = data.get("provider", "deepseek") + messages = data.get("messages", []) + + if provider not in services: + return Response( + f"data: {json.dumps({'error': '未知的 provider'})}\n\n", + mimetype="text/event-stream", + ) + + if not messages: + return Response( + f"data: {json.dumps({'error': 'messages 不能为空'})}\n\n", + mimetype="text/event-stream", + ) + + service = services[provider] + + def generate(): + """生成 SSE 事件流""" + try: + for chunk in service.stream_chat(messages): + payload = json.dumps({"content": chunk}, ensure_ascii=False) + yield f"data: {payload}\n\n" + yield "data: [DONE]\n\n" + except Exception as e: # noqa: BLE001 + err = json.dumps({"error": str(e)}, ensure_ascii=False) + yield f"data: {err}\n\n" + + headers = { + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + } + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers=headers, + ) + + +if __name__ == "__main__": + port = int(os.getenv("PORT", "5000")) + app.run(host="0.0.0.0", port=port, debug=True, threaded=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1d3e0e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +requests==2.32.3 +python-dotenv==1.0.1 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..3b10a0e --- /dev/null +++ b/services/__init__.py @@ -0,0 +1 @@ +# services 包 diff --git a/services/deepseek.py b/services/deepseek.py new file mode 100644 index 0000000..2f2d0cd --- /dev/null +++ b/services/deepseek.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +""" +DeepSeek API 封装 +使用 OpenAI 兼容接口 +""" +import os +import json +import requests + + +class DeepSeekService: + """DeepSeek 流式对话服务""" + + def __init__(self): + self.api_key = os.getenv("DEEPSEEK_API_KEY", "") + self.model = os.getenv("DEEPSEEK_MODEL", "deepseek-chat") + self.base_url = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com") + + def stream_chat(self, messages): + """ + 流式对话,逐块 yield 文本内容 + :param messages: [{"role": "user"|"assistant"|"system", "content": str}] + """ + if not self.api_key: + raise RuntimeError("未配置 DEEPSEEK_API_KEY,请在 .env 中设置") + + url = f"{self.base_url}/chat/completions" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + body = { + "model": self.model, + "messages": messages, + "stream": True, + } + + with requests.post( + url, headers=headers, json=body, stream=True, timeout=120 + ) as resp: + if resp.status_code != 200: + raise RuntimeError( + f"DeepSeek 接口错误 {resp.status_code}: {resp.text}" + ) + for line in resp.iter_lines(decode_unicode=True): + if not line: + continue + if line.startswith("data:"): + data = line[5:].strip() + if data == "[DONE]": + break + try: + obj = json.loads(data) + delta = obj.get("choices", [{}])[0].get("delta", {}) + content = delta.get("content") + if content: + yield content + except json.JSONDecodeError: + continue diff --git a/services/doubao.py b/services/doubao.py new file mode 100644 index 0000000..3f0f72d --- /dev/null +++ b/services/doubao.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +""" +豆包(火山方舟)API 封装 +使用 OpenAI 兼容接口 +""" +import os +import json +import requests + + +class DoubaoService: + """豆包流式对话服务""" + + def __init__(self): + self.api_key = os.getenv("DOUBAO_API_KEY", "") + self.model = os.getenv("DOUBAO_MODEL", "doubao-1-5-pro-32k-250115") + self.base_url = os.getenv( + "DOUBAO_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3" + ) + + def stream_chat(self, messages): + """ + 流式对话,逐块 yield 文本内容 + :param messages: [{"role": "user"|"assistant"|"system", "content": str}] + """ + if not self.api_key: + raise RuntimeError("未配置 DOUBAO_API_KEY,请在 .env 中设置") + + url = f"{self.base_url}/chat/completions" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + body = { + "model": self.model, + "messages": messages, + "stream": True, + } + + with requests.post( + url, headers=headers, json=body, stream=True, timeout=120 + ) as resp: + if resp.status_code != 200: + raise RuntimeError( + f"豆包接口错误 {resp.status_code}: {resp.text}" + ) + for line in resp.iter_lines(decode_unicode=True): + if not line: + continue + if line.startswith("data:"): + data = line[5:].strip() + if data == "[DONE]": + break + try: + obj = json.loads(data) + delta = obj.get("choices", [{}])[0].get("delta", {}) + content = delta.get("content") + if content: + yield content + except json.JSONDecodeError: + continue diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..d3ea681 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,150 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Microsoft YaHei", sans-serif; + background: #f5f6fa; + color: #1f2937; + height: 100vh; + overflow: hidden; +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; + max-width: 900px; + margin: 0 auto; + background: #fff; + box-shadow: 0 0 24px rgba(0, 0, 0, 0.06); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-bottom: 1px solid #e5e7eb; + background: linear-gradient(90deg, #6366f1, #8b5cf6); + color: #fff; +} + +.header h1 { + font-size: 18px; + font-weight: 600; +} + +.provider-switch { + display: flex; + gap: 12px; + font-size: 14px; +} + +.provider-switch label { + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; +} + +.chat { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.message { + display: flex; +} + +.message.user { + justify-content: flex-end; +} + +.bubble { + max-width: 78%; + padding: 10px 14px; + border-radius: 14px; + line-height: 1.6; + white-space: pre-wrap; + word-wrap: break-word; + font-size: 15px; +} + +.message.user .bubble { + background: #6366f1; + color: #fff; + border-bottom-right-radius: 4px; +} + +.message.assistant .bubble { + background: #f1f3f8; + color: #1f2937; + border-bottom-left-radius: 4px; +} + +.composer { + display: flex; + gap: 10px; + padding: 12px 16px; + border-top: 1px solid #e5e7eb; + background: #fafafa; +} + +.composer textarea { + flex: 1; + resize: none; + height: 48px; + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 10px; + font-size: 15px; + font-family: inherit; + outline: none; + transition: border-color 0.2s; +} + +.composer textarea:focus { + border-color: #6366f1; +} + +.composer button { + padding: 0 22px; + border: none; + border-radius: 10px; + background: #6366f1; + color: #fff; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.composer button:hover { + background: #4f46e5; +} + +.composer button:disabled { + background: #a5b4fc; + cursor: not-allowed; +} + +.cursor::after { + content: "▋"; + animation: blink 1s steps(2, start) infinite; + margin-left: 2px; + color: #6366f1; +} + +@keyframes blink { + to { + visibility: hidden; + } +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..2245664 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,119 @@ +// AI 免费对话 - 前端逻辑 +(function () { + const chatEl = document.getElementById("chat"); + const inputEl = document.getElementById("input"); + const sendBtn = document.getElementById("send"); + + // 保存对话历史(用于多轮上下文) + const history = []; + let sending = false; + + /** 获取当前选择的 provider */ + function getProvider() { + const el = document.querySelector('input[name="provider"]:checked'); + return el ? el.value : "deepseek"; + } + + /** 追加一条消息到界面 */ + function appendMessage(role, content) { + const wrapper = document.createElement("div"); + wrapper.className = `message ${role}`; + const bubble = document.createElement("div"); + bubble.className = "bubble"; + bubble.textContent = content; + wrapper.appendChild(bubble); + chatEl.appendChild(wrapper); + chatEl.scrollTop = chatEl.scrollHeight; + return bubble; + } + + /** 发送消息并接收 SSE 流 */ + async function send() { + if (sending) return; + const text = inputEl.value.trim(); + if (!text) return; + + sending = true; + sendBtn.disabled = true; + inputEl.value = ""; + + // 展示用户消息 + appendMessage("user", text); + history.push({ role: "user", content: text }); + + // 展示助手占位 + const bubble = appendMessage("assistant", ""); + bubble.classList.add("cursor"); + + let assistantText = ""; + + try { + const resp = await fetch("api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: getProvider(), + messages: history, + }), + }); + + if (!resp.ok || !resp.body) { + throw new Error("网络请求失败"); + } + + const reader = resp.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // 按 SSE 事件分隔(\n\n) + const parts = buffer.split("\n\n"); + buffer = parts.pop(); // 保留最后不完整片段 + + for (const part of parts) { + const line = part.trim(); + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data === "[DONE]") continue; + try { + const obj = JSON.parse(data); + if (obj.error) { + assistantText += `\n[错误] ${obj.error}`; + bubble.textContent = assistantText; + } else if (obj.content) { + assistantText += obj.content; + bubble.textContent = assistantText; + chatEl.scrollTop = chatEl.scrollHeight; + } + } catch (e) { + // 忽略解析错误 + } + } + } + } catch (err) { + assistantText += `\n[错误] ${err.message}`; + bubble.textContent = assistantText; + } finally { + bubble.classList.remove("cursor"); + if (assistantText) { + history.push({ role: "assistant", content: assistantText }); + } + sending = false; + sendBtn.disabled = false; + inputEl.focus(); + } + } + + // 事件绑定 + sendBtn.addEventListener("click", send); + inputEl.addEventListener("keydown", function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } + }); +})(); diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..46f4ca8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,39 @@ + + + + + + AI 免费对话 + + + +
+
+

🤖 AI 免费对话

+
+ + +
+
+ +
+
+
你好!我是 AI 助手,有什么可以帮你的吗?
+
+
+ +
+ + +
+
+ + + +