Skip to content

添加AI助理功能 #710

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: 3.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spug_api/apps/setting/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 助理接口
]
66 changes: 66 additions & 0 deletions spug_api/apps/setting/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
147 changes: 132 additions & 15 deletions spug_web/src/layout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(<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) {
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 (
<Layout>
<Sider collapsed={collapsed}/>
<Layout style={{height: '100vh'}}>
<Header collapsed={collapsed} toggle={() => setCollapsed(!collapsed)}/>
<Sider collapsed={collapsed} />
<Layout style={{ height: '100vh' }}>
<Header collapsed={collapsed} toggle={() => setCollapsed(!collapsed)} />
<Layout.Content className={styles.content} id="spug-container">
<Switch>
{Routes}
<Route component={NotFound}/>
<Route component={NotFound} />
</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>
<Footer/>
<Footer />
</Layout>
</Layout>
)
);
}