diff --git a/spug_api/apps/setting/urls.py b/spug_api/apps/setting/urls.py index 659702230..333965d26 100644 --- a/spug_api/apps/setting/urls.py +++ b/spug_api/apps/setting/urls.py @@ -15,4 +15,5 @@ url(r'^about/$', get_about), url(r'^push/bind/$', handle_push_bind), url(r'^push/balance/$', handle_push_balance), + url(r'^ai_assistant/$', ai_assistant), # 新增 AI 助理接口 ] diff --git a/spug_api/apps/setting/views.py b/spug_api/apps/setting/views.py index 67bfebcba..e35ef3f6d 100644 --- a/spug_api/apps/setting/views.py +++ b/spug_api/apps/setting/views.py @@ -14,6 +14,9 @@ from copy import deepcopy import platform import ldap +from django.http import StreamingHttpResponse +from openai import OpenAI +import json class SettingView(AdminView): @@ -146,3 +149,66 @@ def handle_push_balance(request): return json_response(error='请先配置推送服务绑定账户') res = get_balance(token) return json_response(res) + + +@auth('admin') +def ai_assistant(request): + """ + 使用 DashScope 接入大模型,通过 openai 库流式返回生成结果,支持上下文对话 + """ + print(request.body) + try: + # 解析请求 + form = json.loads(request.body) + question = form.get('question') + context = form.get('context', []) + if not question: + return JsonResponse({"error": "请输入问题"}, status=400) + except Exception as e: + return JsonResponse({"error": f"请求解析失败:{e}"}, status=400) + + api_key = "sk-d4f98b80a3064eed843aa670eee486b4" + if not api_key: + return JsonResponse({"error": "未配置 DashScope API Key,请在系统设置中配置。"}, status=400) + + try: + # 初始化 OpenAI 客户端 + client = OpenAI( + api_key=api_key, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1" + ) + + # 构建消息列表 + messages = context + [ + {"role": "user", "content": question} + ] + + # 调用 DashScope API + completion = client.chat.completions.create( + model="qwen-plus", + messages=messages, + stream=True, + stream_options={"include_usage": True} + ) + + # 流式返回 + def stream_response(): + try: + for chunk in completion: + # 参考阿里云代码,跳过 usage chunk + if chunk.choices: + delta_content = chunk.choices[0].delta.content + if delta_content: # 确保内容非空 + yield delta_content + # else: usage chunk,忽略 + except Exception as e: + yield json.dumps({"error": f"流式响应错误:{str(e)}"}) + finally: + completion.close() # 释放资源 + + return StreamingHttpResponse( + stream_response(), + content_type="text/plain; charset=utf-8" + ) + except Exception as e: + return JsonResponse({"error": f"调用 DashScope API 失败:{e}"}, status=500) \ No newline at end of file diff --git a/spug_web/src/layout/index.js b/spug_web/src/layout/index.js index e27e842b3..ea9f1313c 100644 --- a/spug_web/src/layout/index.js +++ b/spug_web/src/layout/index.js @@ -5,54 +5,171 @@ */ import React, { useState, useEffect } from 'react'; import { Switch, Route } from 'react-router-dom'; -import { Layout, message } from 'antd'; +import { Layout, message, Drawer, Button, Input } from 'antd'; import { NotFound } from 'components'; import Sider from './Sider'; import Header from './Header'; -import Footer from './Footer' +import Footer from './Footer'; import routes from '../routes'; import { hasPermission, isMobile } from 'libs'; import styles from './layout.module.less'; +import { RobotOutlined } from '@ant-design/icons'; // AI 助理图标 +import Markdown from 'markdown-to-jsx'; // 引入 markdown-to-jsx function initRoutes(Routes, routes) { for (let route of routes) { if (route.component) { if (!route.auth || hasPermission(route.auth)) { - Routes.push() + Routes.push(); } } else if (route.child) { - initRoutes(Routes, route.child) + initRoutes(Routes, route.child); } } } export default function () { - const [collapsed, setCollapsed] = useState(false) + const [collapsed, setCollapsed] = useState(false); const [Routes, setRoutes] = useState([]); + const [drawerVisible, setDrawerVisible] = useState(false); // 控制抽屉显示 + const [messages, setMessages] = useState([]); // 存储对话消息 + const [loading, setLoading] = useState(false); // 控制加载状态 + const [context, setContext] = useState([]); // 新增上下文状态 useEffect(() => { - if (isMobile) { + if (isMobile) { setCollapsed(true); - message.warn('检测到您在移动设备上访问,请使用横屏模式。', 5) + message.warn('检测到您在移动设备上访问,请使用横屏模式。', 5); } const Routes = []; initRoutes(Routes, routes); - setRoutes(Routes) - }, []) + setRoutes(Routes); + }, []); + + const toggleDrawer = () => { + setDrawerVisible(!drawerVisible); + }; + + const handleSendMessage = async (value) => { + if (!value.trim()) return; + const userMessage = { role: 'user', content: value }; + setMessages((prev) => [...prev, userMessage]); // 添加用户消息 + setContext((prev) => [...prev, userMessage]); // 更新上下文 + setLoading(true); + + try { + const X_TOKEN = localStorage.getItem('token'); + const response = await fetch('/api/setting/ai_assistant/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Token': X_TOKEN, + }, + body: JSON.stringify({ question: value, context }), + }); + + if (!response.body) { + throw new Error('No response body'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let done = false; + let assistantMessage = { role: 'assistant', content: '' }; + + while (!done) { + const { value, done: readerDone } = await reader.read(); + done = readerDone; + if (value) { + const chunk = decoder.decode(value, { stream: true }); + assistantMessage.content += chunk; + setMessages((prev) => { + const updatedMessages = [...prev]; + const lastMessage = updatedMessages[updatedMessages.length - 1]; + if (lastMessage?.role === 'assistant') { + lastMessage.content += chunk; + } else { + updatedMessages.push(assistantMessage); + } + return updatedMessages; + }); + } + } + + // 更新上下文 + setContext((prev) => [...prev, assistantMessage]); + } catch (error) { + console.error('Error fetching AI response:', error); + message.error('AI 助理接口调用失败,请稍后重试。'); + } finally { + setLoading(false); + } + }; return ( - - -
setCollapsed(!collapsed)}/> + + +
setCollapsed(!collapsed)} /> {Routes} - + + {/* AI 助理图标 */} +