215 lines
8.2 KiB
Python
215 lines
8.2 KiB
Python
"""
|
||
艾叶 IM Bridge — 个人网页通道
|
||
对接方式:
|
||
1. REST API — POST /api/web/chat
|
||
2. WebSocket — ws://host:port/ws/web/chat
|
||
|
||
提供即开即用的网页聊天入口,也可作为第三方系统接入的通用 API。
|
||
|
||
配置项:
|
||
allowed_origins: ["*"] # CORS 白名单
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import uuid
|
||
from typing import Any, Dict, Set
|
||
|
||
from fastapi import Request, WebSocket, WebSocketDisconnect
|
||
from fastapi.responses import HTMLResponse
|
||
|
||
from core.channel_base import ChannelBase, InboundMessage, MessageType, OutboundMessage
|
||
|
||
logger = logging.getLogger("aiye.channel.web")
|
||
|
||
|
||
class WebChannel(ChannelBase):
|
||
"""个人网页通道(REST + WebSocket)"""
|
||
|
||
_ws_connections: Dict[str, WebSocket] = {}
|
||
|
||
@property
|
||
def platform(self) -> str:
|
||
return "web"
|
||
|
||
async def start(self) -> None:
|
||
logger.info("网页通道已就绪,API: /api/web/chat | WS: /ws/web/chat")
|
||
|
||
async def stop(self) -> None:
|
||
for ws in self._ws_connections.values():
|
||
try:
|
||
await ws.close()
|
||
except Exception:
|
||
pass
|
||
self._ws_connections.clear()
|
||
logger.info("网页通道已停止")
|
||
|
||
async def send(self, msg: OutboundMessage) -> bool:
|
||
ws = self._ws_connections.get(msg.chat_id)
|
||
if ws:
|
||
try:
|
||
await ws.send_json({"type": "reply", "content": msg.content})
|
||
return True
|
||
except Exception as e:
|
||
logger.warning("WebSocket 发送失败: %s", e)
|
||
return True
|
||
|
||
def register_routes(self, app: Any) -> None:
|
||
channel = self
|
||
|
||
@app.post("/api/web/chat")
|
||
async def web_chat_api(request: Request):
|
||
"""REST 聊天接口:{"message": "你好", "session_id": "可选"}"""
|
||
try:
|
||
data = await request.json()
|
||
except Exception:
|
||
return {"code": 400, "msg": "invalid json"}
|
||
|
||
message = data.get("message", "").strip()
|
||
session_id = data.get("session_id", "") or str(uuid.uuid4())[:8]
|
||
if not message:
|
||
return {"code": 400, "msg": "empty message"}
|
||
|
||
inbound = InboundMessage(
|
||
channel_id=channel.channel_id,
|
||
platform=channel.platform,
|
||
sender_id=session_id,
|
||
sender_name=data.get("name", "网页用户"),
|
||
chat_id=session_id,
|
||
content=message,
|
||
)
|
||
|
||
reply = await channel.dispatch(inbound)
|
||
return {
|
||
"code": 0,
|
||
"reply": reply.content if reply else "",
|
||
"session_id": session_id,
|
||
}
|
||
|
||
@app.websocket("/ws/web/chat")
|
||
async def web_chat_ws(websocket: WebSocket):
|
||
"""WebSocket 聊天接口"""
|
||
await websocket.accept()
|
||
ws_id = str(uuid.uuid4())[:8]
|
||
channel._ws_connections[ws_id] = websocket
|
||
await websocket.send_json({"type": "connected", "session_id": ws_id})
|
||
logger.info("WebSocket 连接: %s", ws_id)
|
||
|
||
try:
|
||
while True:
|
||
raw = await websocket.receive_text()
|
||
try:
|
||
data = json.loads(raw)
|
||
message = data.get("message", "").strip()
|
||
except Exception:
|
||
message = raw.strip()
|
||
|
||
if not message:
|
||
continue
|
||
|
||
inbound = InboundMessage(
|
||
channel_id=channel.channel_id,
|
||
platform=channel.platform,
|
||
sender_id=ws_id,
|
||
sender_name="网页用户",
|
||
chat_id=ws_id,
|
||
content=message,
|
||
)
|
||
|
||
reply = await channel.dispatch(inbound)
|
||
if reply:
|
||
await websocket.send_json({
|
||
"type": "reply",
|
||
"content": reply.content,
|
||
})
|
||
except WebSocketDisconnect:
|
||
logger.info("WebSocket 断开: %s", ws_id)
|
||
except Exception as e:
|
||
logger.warning("WebSocket 异常: %s", e)
|
||
finally:
|
||
channel._ws_connections.pop(ws_id, None)
|
||
|
||
@app.get("/chat", response_class=HTMLResponse)
|
||
async def web_chat_page():
|
||
"""内嵌网页聊天界面"""
|
||
html_path = channel._config.get("html_path", "")
|
||
if html_path:
|
||
try:
|
||
from pathlib import Path
|
||
|
||
return Path(html_path).read_text(encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
return _DEFAULT_CHAT_HTML
|
||
|
||
|
||
_DEFAULT_CHAT_HTML = """<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>艾叶 · 卡若AI 聊天</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;height:100vh;display:flex;flex-direction:column}
|
||
.header{background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff;padding:16px 20px;font-size:18px;font-weight:600;text-align:center}
|
||
.chat-box{flex:1;overflow-y:auto;padding:16px 20px;display:flex;flex-direction:column;gap:12px}
|
||
.msg{max-width:80%;padding:10px 14px;border-radius:16px;font-size:15px;line-height:1.5;word-break:break-word;animation:fadeIn .3s}
|
||
.msg.user{align-self:flex-end;background:#667eea;color:#fff;border-bottom-right-radius:4px}
|
||
.msg.ai{align-self:flex-start;background:#fff;color:#333;border-bottom-left-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.1)}
|
||
.msg.ai pre{background:#f5f5f5;padding:8px;border-radius:6px;overflow-x:auto;font-size:13px;margin:6px 0}
|
||
.input-area{display:flex;gap:8px;padding:12px 20px;background:#fff;border-top:1px solid #e0e0e0}
|
||
.input-area input{flex:1;border:1px solid #ddd;border-radius:20px;padding:10px 16px;font-size:15px;outline:none}
|
||
.input-area input:focus{border-color:#667eea}
|
||
.input-area button{background:#667eea;color:#fff;border:none;border-radius:20px;padding:10px 20px;font-size:15px;cursor:pointer}
|
||
.input-area button:hover{background:#5a6fd6}
|
||
.typing{align-self:flex-start;color:#999;font-size:13px;padding:4px 14px}
|
||
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">艾叶 · 卡若AI</div>
|
||
<div class="chat-box" id="chatBox">
|
||
<div class="msg ai">你好!我是卡若AI,通过艾叶 IM 为你服务。有什么可以帮你的?</div>
|
||
</div>
|
||
<div class="input-area">
|
||
<input id="msgInput" placeholder="输入消息..." autocomplete="off">
|
||
<button onclick="sendMsg()">发送</button>
|
||
</div>
|
||
<script>
|
||
const chatBox=document.getElementById('chatBox'),input=document.getElementById('msgInput');
|
||
let ws,sessionId='';
|
||
function connect(){
|
||
const proto=location.protocol==='https:'?'wss:':'ws:';
|
||
ws=new WebSocket(`${proto}//${location.host}/ws/web/chat`);
|
||
ws.onopen=()=>console.log('connected');
|
||
ws.onmessage=e=>{
|
||
const d=JSON.parse(e.data);
|
||
if(d.type==='connected'){sessionId=d.session_id;return}
|
||
if(d.type==='reply'){removeTyping();addMsg(d.content,'ai')}
|
||
};
|
||
ws.onclose=()=>setTimeout(connect,3000);
|
||
}
|
||
function addMsg(text,who){
|
||
const d=document.createElement('div');d.className='msg '+who;
|
||
d.innerHTML=who==='ai'?text.replace(/\\n/g,'<br>'):escHtml(text);
|
||
chatBox.appendChild(d);chatBox.scrollTop=chatBox.scrollHeight;
|
||
}
|
||
function showTyping(){
|
||
if(document.getElementById('typing'))return;
|
||
const d=document.createElement('div');d.id='typing';d.className='typing';d.textContent='卡若AI 正在思考...';
|
||
chatBox.appendChild(d);chatBox.scrollTop=chatBox.scrollHeight;
|
||
}
|
||
function removeTyping(){const t=document.getElementById('typing');if(t)t.remove()}
|
||
function escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||
function sendMsg(){
|
||
const m=input.value.trim();if(!m||!ws||ws.readyState!==1)return;
|
||
addMsg(m,'user');ws.send(JSON.stringify({message:m}));input.value='';showTyping();
|
||
}
|
||
input.addEventListener('keydown',e=>{if(e.key==='Enter')sendMsg()});
|
||
connect();
|
||
</script>
|
||
</body>
|
||
</html>"""
|