GLM对话机器人
·
通过 GLM得key 做一个 翻页版多轮对话会话

# -*- coding: utf-8 -*-
"""
GLM 网页版多轮对话机器人
================================
后端: FastAPI + SSE 流式
前端: 原生 HTML/CSS/JS (内嵌)
存储: SQLite
运行:
python app.py
然后浏览器打开:
http://127.0.0.1:8000
"""
import os
import json
import time
import uuid
import sqlite3
import asyncio
import threading
from typing import List, Dict, Optional, AsyncGenerator
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from zhipuai import ZhipuAI
# ============================================================
# 0. 配置
# ============================================================
os.environ.setdefault("GLM_API_KEY", ".uI3dhsdChCkiOEvt")
API_KEY = os.environ["GLM_API_KEY"]
# 用高版本模型
MODEL = "glm-4-plus" # 高质量;想省钱可换 glm-4-flash
FALLBACK_MODEL = "glm-4-flash" # 出错时降级
DB_PATH = "./chat.db"
SYSTEM_PROMPT = "你是一个乐于助人的中文 AI 助手。回答简洁、准确、有条理。"
client = ZhipuAI(api_key=API_KEY)
# ============================================================
# 1. SQLite 存储
# ============================================================
class DB:
def __init__(self, path):
self.conn = sqlite3.connect(path, check_same_thread=False)
self.conn.execute("PRAGMA journal_mode=WAL")
self._lock = threading.Lock()
self._init()
def _init(self):
with self._lock:
c = self.conn.cursor()
c.execute("""CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, title TEXT, created REAL, updated REAL
)""")
c.execute("""CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT, role TEXT, content TEXT, ts REAL
)""")
c.execute("CREATE INDEX IF NOT EXISTS idx_msg_session ON messages(session_id)")
self.conn.commit()
def create_session(self, title="新对话") -> str:
sid = str(uuid.uuid4())
now = time.time()
with self._lock:
self.conn.execute(
"INSERT INTO sessions(id,title,created,updated) VALUES(?,?,?,?)",
(sid, title, now, now)
)
self.conn.commit()
return sid
def list_sessions(self) -> List[Dict]:
with self._lock:
cur = self.conn.execute(
"SELECT id,title,created,updated FROM sessions ORDER BY updated DESC"
)
rows = cur.fetchall()
return [{"id": r[0], "title": r[1], "created": r[2], "updated": r[3]} for r in rows]
def rename_session(self, sid, title):
with self._lock:
self.conn.execute("UPDATE sessions SET title=?,updated=? WHERE id=?",
(title, time.time(), sid))
self.conn.commit()
def delete_session(self, sid):
with self._lock:
self.conn.execute("DELETE FROM sessions WHERE id=?", (sid,))
self.conn.execute("DELETE FROM messages WHERE session_id=?", (sid,))
self.conn.commit()
def session_exists(self, sid) -> bool:
with self._lock:
cur = self.conn.execute("SELECT 1 FROM sessions WHERE id=?", (sid,))
return cur.fetchone() is not None
def add_message(self, sid, role, content):
with self._lock:
self.conn.execute(
"INSERT INTO messages(session_id,role,content,ts) VALUES(?,?,?,?)",
(sid, role, content, time.time())
)
self.conn.execute("UPDATE sessions SET updated=? WHERE id=?",
(time.time(), sid))
self.conn.commit()
def get_messages(self, sid, limit=100) -> List[Dict]:
with self._lock:
cur = self.conn.execute(
"SELECT role,content,ts FROM messages WHERE session_id=? ORDER BY id ASC LIMIT ?",
(sid, limit)
)
rows = cur.fetchall()
return [{"role": r[0], "content": r[1], "ts": r[2]} for r in rows]
def touch_title(self, sid, first_user_msg):
"""首次用户发言后自动生成标题"""
title = first_user_msg.strip().replace("\n", " ")[:20] or "新对话"
self.rename_session(sid, title)
DB = DB(DB_PATH)
# ============================================================
# 2. FastAPI
# ============================================================
app = FastAPI(title="GLM Chatbot")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
)
class NewSessionReq(BaseModel):
title: Optional[str] = None
class ChatReq(BaseModel):
session_id: str
message: str
stream: bool = True
@app.get("/api/sessions")
def list_sessions():
return {"sessions": DB.list_sessions()}
@app.post("/api/sessions")
def create_session(req: NewSessionReq):
sid = DB.create_session(req.title or "新对话")
return {"session_id": sid}
@app.get("/api/sessions/{sid}/messages")
def get_messages(sid: str):
if not DB.session_exists(sid):
raise HTTPException(404, "session not found")
return {"messages": DB.get_messages(sid)}
@app.delete("/api/sessions/{sid}")
def delete_session(sid: str):
DB.delete_session(sid)
return {"ok": True}
# ---------- 流式对话 ----------
def build_messages(sid: str, user_msg: str) -> List[Dict]:
"""组装历史 + 系统提示"""
history = DB.get_messages(sid, limit=40)
msgs = [{"role": "system", "content": SYSTEM_PROMPT}]
for h in history:
msgs.append({"role": h["role"], "content": h["content"]})
msgs.append({"role": "user", "content": user_msg})
return msgs
def glm_stream(messages, model=MODEL):
"""调用 GLM 流式接口,yield 文本片段"""
try:
resp = client.chat.completions.create(
model=model,
messages=messages,
stream=True,
temperature=0.7,
max_tokens=4096,
)
for chunk in resp:
delta = chunk.choices[0].delta
if hasattr(delta, "content") and delta.content:
yield delta.content
except Exception as e:
# 降级
if model != FALLBACK_MODEL:
yield from glm_stream(messages, model=FALLBACK_MODEL)
else:
yield f"[错误] {e}"
@app.post("/api/chat")
async def chat(req: ChatReq):
if not DB.session_exists(req.session_id):
raise HTTPException(404, "session not found")
# 是否首条消息,用于自动命名
is_first = len(DB.get_messages(req.session_id, limit=1)) == 0
# 保存用户消息
DB.add_message(req.session_id, "user", req.message)
if is_first:
DB.touch_title(req.session_id, req.message)
messages = build_messages(req.session_id, req.message)
# 注意: build_messages 里已经 append 了 user,但 DB 里也已存,
# 为避免重复,把最后一条 user 去掉再拼一次
# -> 简化: 直接用 messages(历史里已包含刚存的 user,最后再 append 会重复)
# 修正: 重新组装
history = DB.get_messages(req.session_id, limit=40)
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + \
[{"role": h["role"], "content": h["content"]} for h in history]
async def event_gen() -> AsyncGenerator[str, None]:
full = []
loop = asyncio.get_event_loop()
gen = glm_stream(messages)
# 用线程安全的方式从同步生成器读取
q: asyncio.Queue = asyncio.Queue()
def producer():
try:
for piece in gen:
asyncio.run_coroutine_threadsafe(q.put(("delta", piece)), loop)
asyncio.run_coroutine_threadsafe(q.put(("done", None)), loop)
except Exception as e:
asyncio.run_coroutine_threadsafe(q.put(("error", str(e))), loop)
threading.Thread(target=producer, daemon=True).start()
while True:
kind, payload = await q.get()
if kind == "delta":
full.append(payload)
# SSE 格式
yield f"data: {json.dumps({'delta': payload}, ensure_ascii=False)}\n\n"
elif kind == "done":
break
elif kind == "error":
yield f"data: {json.dumps({'error': payload}, ensure_ascii=False)}\n\n"
break
answer = "".join(full)
if answer:
DB.add_message(req.session_id, "assistant", answer)
yield f"data: {json.dumps({'done': True, 'answer': answer}, ensure_ascii=False)}\n\n"
return StreamingResponse(event_gen(), media_type="text/event-stream")
# ============================================================
# 3. 前端 (内嵌 HTML)
# ============================================================
INDEX_HTML = r"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GLM 对话机器人</title>
<style>
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1f232c;
--border: #2a2f3a;
--text: #e6e8ee;
--text-dim: #8b93a7;
--accent: #4f7cff;
--accent-hover: #6b91ff;
--user-bg: #2a3550;
--bot-bg: #1c2029;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); }
#app { display: flex; height: 100vh; }
/* Sidebar */
#sidebar {
width: 260px; background: var(--panel); border-right: 1px solid var(--border);
display: flex; flex-direction: column; transition: width .2s;
}
#sidebar.collapsed { width: 0; overflow: hidden; }
.sb-head { padding: 14px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: center; }
.sb-head h1 { font-size: 15px; margin: 0; flex: 1; font-weight: 600; }
.btn {
background: var(--accent); color: #fff; border: none; padding: 8px 12px;
border-radius: 6px; cursor: pointer; font-size: 13px;
}
.btn:hover { background: var(--accent-hover); }
.btn-ghost { background: transparent; color: var(--text-dim); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--panel-2); color: var(--text); }
.sb-list { flex: 1; overflow-y: auto; padding: 8px; }
.sb-item {
padding: 10px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 4px;
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; color: var(--text-dim);
}
.sb-item:hover { background: var(--panel-2); color: var(--text); }
.sb-item.active { background: var(--panel-2); color: var(--text); border-left: 2px solid var(--accent); }
.sb-item .title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; margin-right: 6px; }
.sb-item .del { opacity: 0; color: #ff6b6b; font-size: 12px; padding: 2px 6px; border-radius: 4px; }
.sb-item:hover .del { opacity: 1; }
.sb-item .del:hover { background: rgba(255,107,107,.15); }
/* Main */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#topbar {
padding: 12px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px; background: var(--panel);
}
#topbar .model { color: var(--text-dim); font-size: 12px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 4px; }
#topbar .title { flex: 1; font-size: 14px; font-weight: 500; }
#chat { flex: 1; overflow-y: auto; padding: 24px; }
.msg { max-width: 800px; margin: 0 auto 20px; display: flex; gap: 12px; }
.avatar { width: 32px; height: 32px; border-radius: 6px; flex-shrink: 0; display: grid; place-items: center; font-size: 12px; font-weight: 600; }
.avatar.user { background: var(--accent); color: #fff; }
.avatar.bot { background: #10a37f; color: #fff; }
.bubble { flex: 1; padding: 12px 16px; border-radius: 10px; line-height: 1.65; font-size: 14px; white-space: pre-wrap; word-wrap: break-word; }
.bubble.user { background: var(--user-bg); }
.bubble.bot { background: var(--bot-bg); border: 1px solid var(--border); }
.bubble .cursor { display: inline-block; width: 8px; height: 16px; background: var(--accent); vertical-align: middle; animation: blink 1s infinite; }
@keyframes blink { 50% { opacity: 0; } }
#composer {
border-top: 1px solid var(--border); padding: 16px 24px; background: var(--panel);
}
.input-wrap {
max-width: 800px; margin: 0 auto; display: flex; gap: 8px; align-items: flex-end;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px;
}
.input-wrap:focus-within { border-color: var(--accent); }
#input {
flex: 1; background: transparent; border: none; outline: none; color: var(--text);
font-size: 14px; line-height: 1.5; resize: none; max-height: 200px; font-family: inherit;
}
#send {
background: var(--accent); color: #fff; border: none; padding: 8px 16px; border-radius: 8px;
cursor: pointer; font-size: 13px; font-weight: 500;
}
#send:disabled { opacity: .4; cursor: not-allowed; }
#send:hover:not(:disabled) { background: var(--accent-hover); }
.empty { text-align: center; color: var(--text-dim); padding: 80px 20px; font-size: 14px; }
.empty h2 { color: var(--text); margin-bottom: 8px; font-weight: 500; }
@media (max-width: 720px) {
#sidebar { position: absolute; z-index: 10; height: 100%; }
#sidebar.collapsed { width: 0; }
}
</style>
</head>
<body>
<div id="app">
<aside id="sidebar">
<div class="sb-head">
<h1>GLM 对话</h1>
<button class="btn" id="newChat">+ 新对话</button>
</div>
<div class="sb-list" id="sbList"></div>
</aside>
<main id="main">
<div id="topbar">
<button class="btn btn-ghost" id="toggleSb">☰</button>
<span class="title" id="chatTitle">新对话</span>
<span class="model" id="modelName">glm-4-plus</span>
</div>
<div id="chat">
<div class="empty" id="empty">
<h2>开始对话</h2>
<p>输入任何问题,GLM 会流式回答你</p>
</div>
</div>
<div id="composer">
<div class="input-wrap">
<textarea id="input" rows="1" placeholder="输入消息,Enter 发送,Shift+Enter 换行"></textarea>
<button id="send">发送</button>
</div>
</div>
</main>
</div>
<script>
const API = "";
let currentSessionId = null;
let isStreaming = false;
// ---------- 工具 ----------
async function api(path, opts = {}) {
const res = await fetch(API + path, {
headers: { "Content-Type": "application/json" },
...opts,
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// ---------- 会话列表 ----------
async function loadSessions() {
const { sessions } = await api("/api/sessions");
const list = document.getElementById("sbList");
list.innerHTML = "";
if (!sessions.length) return;
for (const s of sessions) {
const el = document.createElement("div");
el.className = "sb-item" + (s.id === currentSessionId ? " active" : "");
el.innerHTML = `<span class="title">${escapeHtml(s.title)}</span><span class="del">×</span>`;
el.querySelector(".title").onclick = () => switchSession(s.id);
el.querySelector(".del").onclick = (e) => { e.stopPropagation(); deleteSession(s.id); };
list.appendChild(el);
}
}
async function newSession() {
const { session_id } = await api("/api/sessions", {
method: "POST",
body: JSON.stringify({ title: "新对话" }),
});
currentSessionId = session_id;
await loadSessions();
await switchSession(session_id);
}
async function switchSession(sid) {
currentSessionId = sid;
document.querySelectorAll(".sb-item").forEach(e => e.classList.remove("active"));
const title = await loadMessages(sid);
document.getElementById("chatTitle").textContent = title;
await loadSessions();
}
async function loadMessages(sid) {
const { messages } = await api(`/api/sessions/${sid}/messages`);
const chat = document.getElementById("chat");
const empty = document.getElementById("empty");
chat.innerHTML = "";
if (!messages.length) {
chat.appendChild(empty);
return "新对话";
}
for (const m of messages) {
appendMessage(m.role, m.content, false);
}
scrollBottom();
const sessions = (await api("/api/sessions")).sessions;
const s = sessions.find(x => x.id === sid);
return s ? s.title : "对话";
}
async function deleteSession(sid) {
if (!confirm("确定删除这个对话?")) return;
await api(`/api/sessions/${sid}`, { method: "DELETE" });
if (sid === currentSessionId) {
currentSessionId = null;
document.getElementById("chat").innerHTML = `<div class="empty"><h2>开始对话</h2><p>选择或新建一个会话</p></div>`;
}
await loadSessions();
}
// ---------- 渲染消息 ----------
function appendMessage(role, content, streaming = false) {
const empty = document.getElementById("empty");
if (empty) empty.remove();
const chat = document.getElementById("chat");
const wrap = document.createElement("div");
wrap.className = "msg";
const av = document.createElement("div");
av.className = "avatar " + (role === "user" ? "user" : "bot");
av.textContent = role === "user" ? "我" : "AI";
const bubble = document.createElement("div");
bubble.className = "bubble " + (role === "user" ? "user" : "bot");
bubble.textContent = content;
if (streaming) {
const cur = document.createElement("span");
cur.className = "cursor";
bubble.appendChild(cur);
}
wrap.appendChild(av);
wrap.appendChild(bubble);
chat.appendChild(wrap);
return { bubble, wrap };
}
function scrollBottom() {
const chat = document.getElementById("chat");
chat.scrollTop = chat.scrollHeight;
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
}[c]));
}
// ---------- 发送消息 ----------
async function sendMessage() {
if (isStreaming) return;
const input = document.getElementById("input");
const text = input.value.trim();
if (!text) return;
if (!currentSessionId) {
await newSession();
}
input.value = "";
input.style.height = "auto";
appendMessage("user", text);
scrollBottom();
const { bubble } = appendMessage("assistant", "", true);
isStreaming = true;
document.getElementById("send").disabled = true;
let full = "";
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: currentSessionId,
message: text,
stream: true,
}),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6);
if (!payload.trim()) continue;
try {
const obj = JSON.parse(payload);
if (obj.delta) {
full += obj.delta;
// 保留光标
bubble.textContent = full;
const cur = document.createElement("span");
cur.className = "cursor";
bubble.appendChild(cur);
scrollBottom();
} else if (obj.error) {
full += `\n[错误] ${obj.error}`;
bubble.textContent = full;
} else if (obj.done) {
bubble.textContent = obj.answer || full;
}
} catch (e) {
console.error("SSE parse error", e, payload);
}
}
}
} catch (e) {
bubble.textContent = full + `\n[请求失败] ${e.message}`;
} finally {
isStreaming = false;
document.getElementById("send").disabled = false;
scrollBottom();
// 刷新标题(首条消息后可能被自动命名)
const sessions = (await api("/api/sessions")).sessions;
const s = sessions.find(x => x.id === currentSessionId);
if (s) document.getElementById("chatTitle").textContent = s.title;
loadSessions();
}
}
// ---------- 事件绑定 ----------
document.getElementById("send").onclick = sendMessage;
document.getElementById("newChat").onclick = newSession;
document.getElementById("toggleSb").onclick = () => {
document.getElementById("sidebar").classList.toggle("collapsed");
};
const inputEl = document.getElementById("input");
inputEl.addEventListener("input", () => {
inputEl.style.height = "auto";
inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + "px";
});
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// 初始化
(async () => {
await loadSessions();
const sessions = (await api("/api/sessions")).sessions;
if (sessions.length) {
await switchSession(sessions[0].id);
} else {
await newSession();
}
})();
</script>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
def index():
return INDEX_HTML
# ============================================================
# 4. 启动
# ============================================================
if __name__ == "__main__":
import uvicorn
print("=" * 50)
print("GLM 对话机器人")
print("打开浏览器访问: http://127.0.0.1:8000")
print("=" * 50)
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)