ai chat init
This commit is contained in:
26
backend/ai/chat.py
Normal file
26
backend/ai/chat.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
import json
|
||||||
|
from ai.langchain_client import get_ai_reply_stream
|
||||||
|
from ai.utils import get_first_available_ai_config
|
||||||
|
|
||||||
|
|
||||||
|
class ChatConsumer(AsyncWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def receive(self, text_data):
|
||||||
|
data = json.loads(text_data)
|
||||||
|
user_message = data.get("message", "")
|
||||||
|
|
||||||
|
model, api_key, api_base = await get_first_available_ai_config()
|
||||||
|
|
||||||
|
async def send_chunk(chunk):
|
||||||
|
await self.send(text_data=json.dumps({"is_streaming": True, "message": chunk}))
|
||||||
|
|
||||||
|
await get_ai_reply_stream(user_message, send_chunk, model_name=model, api_key=api_key, api_base=api_base)
|
||||||
|
|
||||||
|
# 结束标记
|
||||||
|
await self.send(text_data=json.dumps({"done": True}))
|
||||||
25
backend/ai/langchain_client.py
Normal file
25
backend/ai/langchain_client.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from langchain.schema import HumanMessage
|
||||||
|
|
||||||
|
from langchain_core.callbacks import AsyncCallbackHandler
|
||||||
|
from langchain_community.chat_models import ChatOpenAI
|
||||||
|
|
||||||
|
|
||||||
|
class MyHandler(AsyncCallbackHandler):
|
||||||
|
def __init__(self, send_func):
|
||||||
|
super().__init__()
|
||||||
|
self.send_func = send_func
|
||||||
|
|
||||||
|
async def on_llm_new_token(self, token: str, **kwargs):
|
||||||
|
await self.send_func(token)
|
||||||
|
|
||||||
|
async def get_ai_reply_stream(message: str, send_func, api_key, api_base, model_name):
|
||||||
|
# 实例化时就带回调
|
||||||
|
chat = ChatOpenAI(
|
||||||
|
openai_api_key=api_key,
|
||||||
|
openai_api_base=api_base,
|
||||||
|
model_name=model_name,
|
||||||
|
temperature=0.7,
|
||||||
|
streaming=True,
|
||||||
|
callbacks=[MyHandler(send_func)]
|
||||||
|
)
|
||||||
|
await chat.ainvoke([HumanMessage(content=message)])
|
||||||
@@ -218,14 +218,12 @@ class ChatRole(CoreModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name="roles",
|
related_name="roles",
|
||||||
verbose_name="关联的知识库",
|
verbose_name="关联的知识库",
|
||||||
db_comment="关联的知识库"
|
|
||||||
)
|
)
|
||||||
tools = models.ManyToManyField(
|
tools = models.ManyToManyField(
|
||||||
'Tool',
|
'Tool',
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="roles",
|
related_name="roles",
|
||||||
verbose_name="关联的工具",
|
verbose_name="关联的工具",
|
||||||
db_comment="关联的工具"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
7
backend/ai/routing.py
Normal file
7
backend/ai/routing.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from ai.chat import ChatConsumer
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r'ws/chat/$', ChatConsumer.as_asgi()),
|
||||||
|
]
|
||||||
11
backend/ai/utils.py
Normal file
11
backend/ai/utils.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from ai.models import AIModel
|
||||||
|
from utils.models import CommonStatus
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
|
@sync_to_async
|
||||||
|
def get_first_available_ai_config():
|
||||||
|
# 这里只取第一个可用的,可以根据实际业务加筛选条件
|
||||||
|
ai = AIModel.objects.filter(status=CommonStatus.ENABLED).prefetch_related('key').first()
|
||||||
|
if not ai:
|
||||||
|
raise Exception('没有可用的AI配置')
|
||||||
|
return ai.model, ai.key.api_key, ai.key.url
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
"""
|
|
||||||
ASGI config for backend project.
|
|
||||||
|
|
||||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
|
||||||
|
|
||||||
application = get_asgi_application()
|
# 延迟导入,避免 AppRegistryNotReady 错误
|
||||||
|
def get_websocket_urlpatterns():
|
||||||
|
from ai.routing import websocket_urlpatterns
|
||||||
|
return websocket_urlpatterns
|
||||||
|
|
||||||
|
application = ProtocolTypeRouter({
|
||||||
|
"http": get_asgi_application(),
|
||||||
|
"websocket": URLRouter(
|
||||||
|
get_websocket_urlpatterns()
|
||||||
|
),
|
||||||
|
})
|
||||||
@@ -53,6 +53,7 @@ INSTALLED_APPS = [
|
|||||||
'django_filters',
|
'django_filters',
|
||||||
'corsheaders',
|
'corsheaders',
|
||||||
'rest_framework.authtoken',
|
'rest_framework.authtoken',
|
||||||
|
'channels',
|
||||||
"system",
|
"system",
|
||||||
"ai",
|
"ai",
|
||||||
]
|
]
|
||||||
@@ -231,5 +232,15 @@ LOGGING = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ASGI_APPLICATION = 'backend.asgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# 简单用内存通道层
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'channels.layers.InMemoryChannelLayer'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if os.path.exists(os.path.join(BASE_DIR, 'backend/local_settings.py')):
|
if os.path.exists(os.path.join(BASE_DIR, 'backend/local_settings.py')):
|
||||||
from backend.local_settings import *
|
from backend.local_settings import *
|
||||||
@@ -14,3 +14,8 @@ goofish_api==0.0.6
|
|||||||
flower==2.0.1
|
flower==2.0.1
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
django_redis==6.0.0
|
django_redis==6.0.0
|
||||||
|
django-ninja==1.4.3
|
||||||
|
openai==1.95
|
||||||
|
daphne==4.2.1
|
||||||
|
langchain==0.3.26
|
||||||
|
langchain-community==0.3.27
|
||||||
285
web/apps/web-antd/src/views/ai/chat/index.vue
Normal file
285
web/apps/web-antd/src/views/ai/chat/index.vue
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
type: 'ai' | 'system' | 'user' | string;
|
||||||
|
content: string;
|
||||||
|
isTyping?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = ref<string>('');
|
||||||
|
const messages = ref<Message[]>([]);
|
||||||
|
const loading = ref<boolean>(false);
|
||||||
|
const isConnected = ref<boolean>(false);
|
||||||
|
let socket: null | WebSocket = null;
|
||||||
|
const messagesRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
const wsUrl = `${protocol}//${host}/ws/chat/`;
|
||||||
|
|
||||||
|
socket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
isConnected.value = true;
|
||||||
|
messages.value.push({ type: 'system', content: '✅ WebSocket 连接成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('message', (event: MessageEvent<string>) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.done) {
|
||||||
|
loading.value = false;
|
||||||
|
if (
|
||||||
|
messages.value.length > 0 &&
|
||||||
|
messages.value[messages.value.length - 1]?.type === 'ai'
|
||||||
|
) {
|
||||||
|
const lastMsg = messages.value[messages.value.length - 1];
|
||||||
|
if (lastMsg) {
|
||||||
|
lastMsg.isTyping = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.is_streaming) {
|
||||||
|
if (
|
||||||
|
messages.value.length > 0 &&
|
||||||
|
messages.value[messages.value.length - 1]?.type === 'ai'
|
||||||
|
) {
|
||||||
|
const currentMessage = messages.value[messages.value.length - 1];
|
||||||
|
if (currentMessage) {
|
||||||
|
currentMessage.content += data.message;
|
||||||
|
currentMessage.isTyping = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
messages.value.push({
|
||||||
|
type: 'ai',
|
||||||
|
content: data.message,
|
||||||
|
isTyping: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const messageType = data.type || 'system';
|
||||||
|
messages.value.push({ type: messageType, content: data.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('close', (event: CloseEvent) => {
|
||||||
|
isConnected.value = false;
|
||||||
|
messages.value.push({
|
||||||
|
type: 'system',
|
||||||
|
content: `❌ WebSocket 连接已断开 (${event.code})`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('error', () => {
|
||||||
|
isConnected.value = false;
|
||||||
|
messages.value.push({ type: 'system', content: '❌ WebSocket 连接错误' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动滚动到底
|
||||||
|
watch(
|
||||||
|
messages,
|
||||||
|
() => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesRef.value) {
|
||||||
|
messagesRef.value.scrollTop = messagesRef.value.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function send(): void {
|
||||||
|
if (!input.value.trim()) return;
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
loading.value = true;
|
||||||
|
messages.value.push({ type: 'user', content: input.value });
|
||||||
|
socket.send(JSON.stringify({ message: input.value }));
|
||||||
|
input.value = '';
|
||||||
|
} else {
|
||||||
|
messages.value.push({
|
||||||
|
type: 'system',
|
||||||
|
content: '❌ WebSocket 未连接,无法发送消息',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chat-box">
|
||||||
|
<div class="messages" ref="messagesRef">
|
||||||
|
<div class="message" v-for="(msg, index) in messages" :key="index">
|
||||||
|
<span v-if="msg.type === 'user'" class="user-message"
|
||||||
|
>🧑: {{ msg.content }}</span
|
||||||
|
>
|
||||||
|
<span v-else-if="msg.type === 'ai'" class="ai-message"
|
||||||
|
>🤖: {{ msg.content }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="loading">AI 正在思考...</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-box">
|
||||||
|
<input v-model="input" @keyup.enter="send" placeholder="请输入问题..." />
|
||||||
|
<button @click="send">发送</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 80vh;
|
||||||
|
width: 400px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
padding: 8px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-btn {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-btn:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connected {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connecting {
|
||||||
|
color: #ffc107;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disconnected {
|
||||||
|
color: #dc3545;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disconnecting {
|
||||||
|
color: #6c757d;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-unknown {
|
||||||
|
color: #6c757d;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 4px 0;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message {
|
||||||
|
color: #007bff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-message {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-text {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-cursor {
|
||||||
|
color: #28a745;
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
51%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-message {
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-message {
|
||||||
|
color: #17a2b8;
|
||||||
|
font-style: italic;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
color: gray;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-box {
|
||||||
|
display: flex;
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import * as console from 'node:console';
|
|
||||||
|
|
||||||
import { defineConfig } from '@vben/vite-config';
|
import { defineConfig } from '@vben/vite-config';
|
||||||
|
|
||||||
import { loadEnv } from 'vite';
|
import { loadEnv } from 'vite';
|
||||||
@@ -9,8 +7,8 @@ import vitePluginOss from './plugins/vite-plugin-oss.mjs';
|
|||||||
export default defineConfig(async ({ mode }) => {
|
export default defineConfig(async ({ mode }) => {
|
||||||
// eslint-disable-next-line n/prefer-global/process
|
// eslint-disable-next-line n/prefer-global/process
|
||||||
const env = loadEnv(mode, process.cwd());
|
const env = loadEnv(mode, process.cwd());
|
||||||
// 这样获取
|
// 这样获取,提供默认值
|
||||||
const backendUrl = env.VITE_BACKEND_URL;
|
const backendUrl = env.VITE_BACKEND_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
// 判断是否为构建模式
|
// 判断是否为构建模式
|
||||||
const isBuild = mode === 'production';
|
const isBuild = mode === 'production';
|
||||||
@@ -28,6 +26,11 @@ export default defineConfig(async ({ mode }) => {
|
|||||||
target: backendUrl,
|
target: backendUrl,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: backendUrl,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true, // 启用WebSocket代理
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
Reference in New Issue
Block a user