mirror of https://github.com/openspug/spug
Merge 49259f1aa3
into 0e7b5ec77e
commit
9590d26dfb
|
@ -15,4 +15,5 @@ urlpatterns = [
|
||||||
url(r'^about/$', get_about),
|
url(r'^about/$', get_about),
|
||||||
url(r'^push/bind/$', handle_push_bind),
|
url(r'^push/bind/$', handle_push_bind),
|
||||||
url(r'^push/balance/$', handle_push_balance),
|
url(r'^push/balance/$', handle_push_balance),
|
||||||
|
url(r'^ai_assistant/$', ai_assistant), # 新增 AI 助理接口
|
||||||
]
|
]
|
||||||
|
|
|
@ -14,6 +14,9 @@ from apps.setting.models import Setting, KEYS_DEFAULT
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
import platform
|
import platform
|
||||||
import ldap
|
import ldap
|
||||||
|
from django.http import StreamingHttpResponse
|
||||||
|
from openai import OpenAI
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
class SettingView(AdminView):
|
class SettingView(AdminView):
|
||||||
|
@ -146,3 +149,66 @@ def handle_push_balance(request):
|
||||||
return json_response(error='请先配置推送服务绑定账户')
|
return json_response(error='请先配置推送服务绑定账户')
|
||||||
res = get_balance(token)
|
res = get_balance(token)
|
||||||
return json_response(res)
|
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)
|
|
@ -5,40 +5,106 @@
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Switch, Route } from 'react-router-dom';
|
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 { NotFound } from 'components';
|
||||||
import Sider from './Sider';
|
import Sider from './Sider';
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import Footer from './Footer'
|
import Footer from './Footer';
|
||||||
import routes from '../routes';
|
import routes from '../routes';
|
||||||
import { hasPermission, isMobile } from 'libs';
|
import { hasPermission, isMobile } from 'libs';
|
||||||
import styles from './layout.module.less';
|
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) {
|
function initRoutes(Routes, routes) {
|
||||||
for (let route of routes) {
|
for (let route of routes) {
|
||||||
if (route.component) {
|
if (route.component) {
|
||||||
if (!route.auth || hasPermission(route.auth)) {
|
if (!route.auth || hasPermission(route.auth)) {
|
||||||
Routes.push(<Route exact key={route.path} path={route.path} component={route.component}/>)
|
Routes.push(<Route exact key={route.path} path={route.path} component={route.component} />);
|
||||||
}
|
}
|
||||||
} else if (route.child) {
|
} else if (route.child) {
|
||||||
initRoutes(Routes, route.child)
|
initRoutes(Routes, route.child);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [Routes, setRoutes] = useState([]);
|
const [Routes, setRoutes] = useState([]);
|
||||||
|
const [drawerVisible, setDrawerVisible] = useState(false); // 控制抽屉显示
|
||||||
|
const [messages, setMessages] = useState([]); // 存储对话消息
|
||||||
|
const [loading, setLoading] = useState(false); // 控制加载状态
|
||||||
|
const [context, setContext] = useState([]); // 新增上下文状态
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setCollapsed(true);
|
setCollapsed(true);
|
||||||
message.warn('检测到您在移动设备上访问,请使用横屏模式。', 5)
|
message.warn('检测到您在移动设备上访问,请使用横屏模式。', 5);
|
||||||
}
|
}
|
||||||
const Routes = [];
|
const Routes = [];
|
||||||
initRoutes(Routes, 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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
@ -50,9 +116,60 @@ export default function () {
|
||||||
{Routes}
|
{Routes}
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
{/* AI 助理图标 */}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<RobotOutlined />}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 24,
|
||||||
|
right: 24,
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
onClick={toggleDrawer}
|
||||||
|
/>
|
||||||
|
{/* 抽屉对话框 */}
|
||||||
|
<Drawer
|
||||||
|
title="AI 助理"
|
||||||
|
placement="right"
|
||||||
|
onClose={toggleDrawer}
|
||||||
|
visible={drawerVisible}
|
||||||
|
width={400}
|
||||||
|
>
|
||||||
|
<div style={{ height: 'calc(100% - 60px)', overflowY: 'auto', marginBottom: 16 }}>
|
||||||
|
{/* AI 对话区域 */}
|
||||||
|
{messages.map((msg, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
background: msg.role === 'user' ? '#e6f7ff' : '#f5f5f5',
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>{msg.role === 'user' ? '用户:' : 'AI 助理:'}</p>
|
||||||
|
<Markdown>{msg.content}</Markdown> {/* 使用 Markdown 渲染内容 */}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loading && (
|
||||||
|
<div style={{ padding: 16, background: '#f5f5f5', borderRadius: 4 }}>
|
||||||
|
<p>AI 助理正在输入...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 输入框 */}
|
||||||
|
<Input.Search
|
||||||
|
placeholder="请输入内容..."
|
||||||
|
enterButton="发送"
|
||||||
|
onSearch={handleSendMessage}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
<Footer />
|
<Footer />
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue