perf: Chat and model

This commit is contained in:
fit2cloud-chenyw
2025-05-12 19:16:28 +08:00
parent c41c760fe4
commit c7c7416160
16 changed files with 332 additions and 13 deletions
+2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter
from apps.system.api import login, user, aimodel
from apps.settings.api import terminology
from apps.datasource.api import datasource
from apps.chat.api import chat
api_router = APIRouter()
@@ -11,5 +12,6 @@ api_router.include_router(user.router)
api_router.include_router(aimodel.router)
api_router.include_router(terminology.router)
api_router.include_router(datasource.router)
api_router.include_router(chat.router)
View File
View File
+53
View File
@@ -0,0 +1,53 @@
from fastapi import APIRouter, HTTPException
from sqlmodel import select
from apps.chat.schemas.chat_base_schema import LLMConfig
from apps.chat.schemas.chat_schema import ChatQuestion
from apps.chat.schemas.llm import LLMService
from apps.system.models.system_modle import AiModelDetail
from common.core.deps import SessionDep
# from sse_starlette.sse import EventSourceResponse
router = APIRouter(tags=["Data Q&A"], prefix="/chat")
@router.post("/question")
async def stream_sql(session: SessionDep, requestQuestion: ChatQuestion):
question = requestQuestion.question
# Use OpenAI model
""" openai_config = LLMConfig(
model_type="openai",
model_name="gpt-4",
api_key="your-api-key",
additional_params={"temperature": 0.7}
)
openai_service = LLMService(openai_config) """
aimodel = session.exec(select(AiModelDetail).where(AiModelDetail.status == True, AiModelDetail.api_key.is_not(None))).first()
if not aimodel:
raise HTTPException(
status_code=400,
detail="No available AI model configuration found"
)
# Use Tongyi Qianwen
tongyi_config = LLMConfig(
model_type="tongyi",
model_name=aimodel.name,
api_key=aimodel.api_key,
additional_params={"temperature": aimodel.temperature}
)
llm_service = LLMService(tongyi_config)
# Use Custom VLLM model
""" vllm_config = LLMConfig(
model_type="vllm",
model_name="your-model-path",
additional_params={
"max_new_tokens": 200,
"temperature": 0.3
}
)
vllm_service = LLMService(vllm_config) """
result = llm_service.generate_sql(question)
return result
@@ -0,0 +1,86 @@
from pydantic import BaseModel
from typing import Optional, Dict, Any, Type
from abc import ABC, abstractmethod
from langchain_core.language_models import BaseLLM as LangchainBaseLLM
from langchain_openai import ChatOpenAI
from langchain_community.llms import Tongyi, VLLM
class LLMConfig(BaseModel):
"""Base configuration class for large language models"""
model_type: str # Model type: openai/tongyi/vllm etc.
model_name: str # Specific model name
api_key: Optional[str] = None
api_base_url: Optional[str] = None
additional_params: Dict[str, Any] = {}
class BaseLLM(ABC):
"""Abstract base class for large language models"""
def __init__(self, config: LLMConfig):
self.config = config
self._llm = self._init_llm()
@abstractmethod
def _init_llm(self) -> LangchainBaseLLM:
"""Initialize specific large language model instance"""
pass
@property
def llm(self) -> LangchainBaseLLM:
"""Return the langchain LLM instance"""
return self._llm
class OpenAILLM(BaseLLM):
def _init_llm(self) -> LangchainBaseLLM:
return ChatOpenAI(
model=self.config.model_name,
api_key=self.config.api_key,
**self.config.additional_params
)
def generate(self, prompt: str) -> str:
return self.llm.invoke(prompt)
class TongyiLLM(BaseLLM):
def _init_llm(self) -> LangchainBaseLLM:
return Tongyi(
model_name=self.config.model_name,
dashscope_api_key=self.config.api_key,
**self.config.additional_params
)
def generate(self, prompt: str) -> str:
return self.llm.invoke(prompt)
class VLLMLLM(BaseLLM):
def _init_llm(self) -> LangchainBaseLLM:
return VLLM(
model=self.config.model_name,
**self.config.additional_params
)
def generate(self, prompt: str) -> str:
return self.llm.invoke(prompt)
class LLMFactory:
"""Large Language Model Factory Class"""
_llm_types: Dict[str, Type[BaseLLM]] = {
"openai": OpenAILLM,
"tongyi": TongyiLLM,
"vllm": VLLMLLM
}
@classmethod
def create_llm(cls, config: LLMConfig) -> BaseLLM:
llm_class = cls._llm_types.get(config.model_type)
if not llm_class:
raise ValueError(f"Unsupported LLM type: {config.model_type}")
return llm_class(config)
@classmethod
def register_llm(cls, model_type: str, llm_class: Type[BaseLLM]):
"""Register new model type"""
cls._llm_types[model_type] = llm_class
+6
View File
@@ -0,0 +1,6 @@
from pydantic import BaseModel
class ChatQuestion(BaseModel):
question: str
+33
View File
@@ -0,0 +1,33 @@
from langchain_community.utilities import SQLDatabase
# from langchain_community.agent_toolkits import create_sql_agent
from langchain_community.llms import Tongyi
from langchain_core.prompts import ChatPromptTemplate
from apps.chat.schemas.chat_base_schema import LLMConfig, LLMFactory
from common.core.config import settings
import warnings
warnings.filterwarnings("ignore")
class LLMService:
def __init__(self, config: LLMConfig):
# Initialize database connection
self.db = SQLDatabase.from_uri(str(settings.SQLALCHEMY_DATABASE_URI))
# Create LLM instance through factory
llm_instance = LLMFactory.create_llm(config)
self.llm = llm_instance.llm
# Define prompt template
self.prompt = ChatPromptTemplate.from_messages([
("system", """You are a professional SQL engineer. Generate PostgreSQL SELECT queries based on the database schema and user questions.
Data modification or deletion is prohibited. Table structure is as follows:
{schema}
"""),
("human", "{question}")
])
def generate_sql(self, question: str) -> str:
chain = self.prompt | self.llm
schema = self.db.get_table_info()
return chain.invoke({"schema": schema, "question": question})
+8 -1
View File
@@ -49,7 +49,14 @@ class ResponseMiddleware(BaseHTTPMiddleware):
)
except Exception as e:
logging.error(f"Response processing error: {str(e)}", exc_info=True)
return response
return JSONResponse(
status_code=500,
content={
"code": 500,
"data": None,
"msg": str(e)
}
)
return response
+6 -2
View File
@@ -3,13 +3,14 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
import os
import sentry_sdk
from fastapi import FastAPI, Path
from fastapi import FastAPI, Path, HTTPException
from fastapi.routing import APIRoute
from starlette.middleware.cors import CORSMiddleware
from starlette.exceptions import HTTPException as StarletteHTTPException
from apps.api import api_router
from apps.system.middleware.auth import TokenMiddleware
from common.core.config import settings
from common.core.response_middleware import ResponseMiddleware
from common.core.response_middleware import ResponseMiddleware, exception_handler
def custom_generate_unique_id(route: APIRoute) -> str:
tag = route.tags[0] if route.tags and len(route.tags) > 0 else ""
@@ -39,6 +40,9 @@ app.add_middleware(TokenMiddleware)
app.add_middleware(ResponseMiddleware)
app.include_router(api_router, prefix=settings.API_V1_STR)
# Register exception handlers
app.add_exception_handler(StarletteHTTPException, exception_handler.http_exception_handler)
app.add_exception_handler(Exception, exception_handler.global_exception_handler)
frontend_dist = os.path.abspath("../frontend/dist")
if not os.path.exists(frontend_dist):
+7
View File
@@ -19,6 +19,13 @@ dependencies = [
"sentry-sdk[fastapi]<2.0.0,>=1.40.6",
"pyjwt<3.0.0,>=2.8.0",
"pycryptodome (>=3.22.0,<4.0.0)",
"langchain>=0.1.0,<0.2.0",
"langchain-core>=0.1.10,<0.2.0",
"langchain-openai>=0.1.0,<0.2.0",
"langchain-community>=0.0.19,<0.1.0",
"vllm>=0.8.5",
"dashscope>=1.14.0,<2.0.0",
"sse-starlette>=1.8.0,<2.0.0"
"pymysql (>=1.1.1,<2.0.0)",
]
[[tool.uv.index]]
+10
View File
@@ -0,0 +1,10 @@
import { request } from '@/utils/request'
export const questionApi = {
pager: (pageNumber: number, pageSize: number) => request.get(`/chat/question/pager/${pageNumber}/${pageSize}`),
// add: (data: any, progress: any) => request.post('/chat/question', data, { responseType: 'stream', onDownloadProgress: progress }),
add: (data: any) => request.post('/chat/question', data),
edit: (data: any) => request.put('/chat/question', data),
delete: (id: number) => request.delete(`/chat/question/${id}`),
query: (id: number) => request.get(`/chat/question/${id}`)
}
+22
View File
@@ -0,0 +1,22 @@
export interface SelectOption {
label: string
value: string | number
}
export const modelTypeOptions: SelectOption[] = [
{ label: 'OpenAI', value: 0 },
{ label: 'Anthropic', value: 1 },
{ label: 'Baidu', value: 2 },
{ label: 'iFLYTEK', value: 3 },
{ label: 'Zhipu AI', value: 4 },
{ label: 'MiniMax', value: 5 },
{ label: 'Tencent', value: 6 },
{ label: 'Other', value: 7 },
]
export const getModelTypeName = (value: any) => {
const tv = parseInt(value)
const item = modelTypeOptions.find(item => item.value === tv)
return item?.label || ''
}
+5 -1
View File
@@ -49,7 +49,7 @@ class HttpService {
this.cancelTokenSource = axios.CancelToken.source()
this.instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
timeout: 50000,
headers: {
'Content-Type': 'application/json',
...config?.headers
@@ -155,6 +155,10 @@ class HttpService {
default:
errorMessage = `Server responded with error: ${error.response.status}`
}
if (error?.response?.data) {
const msgData: any = error.response.data
msgData.msg && (errorMessage = msgData.msg)
}
} else if (error.request) {
errorMessage = 'No response from server'
} else if (axios.isCancel(error)) {
+85 -5
View File
@@ -50,7 +50,8 @@
class="message"
:class="msg.role"
>
<div class="message-content">{{ msg.content }}</div>
<!-- <div class="message-content">{{ msg.content }}</div> -->
<div class="message-content" v-html="formatMessage(msg.content)"></div>
</div>
</template>
<div v-else class="empty-state">
@@ -87,8 +88,10 @@
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ref, computed, nextTick, watch } from 'vue'
import { Plus, Search, More, Position } from '@element-plus/icons-vue'
import { questionApi } from '@/api/chat'
interface ChatMessage {
role: 'user' | 'assistant'
@@ -105,6 +108,7 @@ const chatHistory = ref<Chat[]>([])
const currentChatId = ref('')
const searchKeyword = ref('')
const inputMessage = ref('')
const messagesRef = ref<HTMLDivElement>()
const filteredHistory = computed(() => {
return chatHistory.value.filter(chat =>
@@ -130,19 +134,72 @@ const switchChat = (chatId: string) => {
currentChatId.value = chatId
}
const scrollToBottom = () => {
nextTick(() => {
if (messagesRef.value) {
const scrollHeight = messagesRef.value.scrollHeight
const animateScroll = () => {
const currentScroll = messagesRef.value!.scrollTop
const distance = scrollHeight - currentScroll
if (distance > 0) {
window.requestAnimationFrame(animateScroll)
messagesRef.value!.scrollTop = currentScroll + distance / 8
}
}
animateScroll()
}
})
}
const sendMessage = () => {
if (!inputMessage.value.trim()) return
const chat = currentChat.value
if (!chat) return
let chat = currentChat.value
// if (!chat) return
if (!chat) {
chat = {
id: null,
title: 'test',
messages: []
} as unknown as Chat
}
chat.messages.push({
role: 'user',
content: inputMessage.value
})
const param = {
question: inputMessage.value
}
inputMessage.value = ''
chatHistory.value.unshift(chat)
currentChatId.value = chat.id
// const index = chat.messages.length - 1
questionApi.add(param).then((result: string) => {
// chat.messages[index]['content'] += result
chat.messages.push({
role: 'assistant',
content: result
})
scrollToBottom()
})
}
watch(() => currentChat.value?.messages, () => {
scrollToBottom()
}, { deep: true })
const formatMessage = (content: string) => {
if (!content) return ''
return content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
.replace(/ {2}/g, '&nbsp;&nbsp;')
}
const handleCommand = (command: string, chatId: string) => {
@@ -253,6 +310,28 @@ const handleCtrlEnter = (e: KeyboardEvent) => {
flex: 1;
overflow-y: auto;
padding: 24px;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
&:hover {
background: #999;
}
}
&::-webkit-scrollbar-track {
background: transparent;
}
&:not(:hover)::-webkit-scrollbar-thumb {
background: transparent;
}
.message {
margin-bottom: 24px;
@@ -266,6 +345,7 @@ const handleCtrlEnter = (e: KeyboardEvent) => {
&.assistant .message-content {
background-color: #f5f5f5;
text-align: left;
}
.message-content {
+9 -4
View File
@@ -32,7 +32,11 @@
<el-table :data="state.tableData" style="width: 100%" row-key="id" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column prop="name" label="Model Name" width="280" />
<el-table-column prop="type" label="Model Type" width="280" />
<el-table-column prop="type" label="Model Type" width="280">
<template #default="scope">
<span>{{ getModelTypeName(scope.row.type) }}</span>
</template>
</el-table-column>
<el-table-column prop="api_key" label="API KEY" width="280">
<template #default="scope">
<div class="user-status-container" :class="[ scope.row.api_key ? 'active' : 'disabled' ]">
@@ -100,9 +104,10 @@
<el-form-item label="Model Type">
<el-select v-model="state.form.type" placeholder="Please select a type">
<el-option label="domain1" value="0" />
<el-option v-for="item in modelTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
<!-- <el-option label="domain1" value="0" />
<el-option label="domain2" value="1" />
<el-option label="domain3" value="2" />
<el-option label="domain3" value="2" /> -->
</el-select>
</el-form-item>
@@ -152,7 +157,7 @@ import IconOpeEdit from '@/assets/svg/operate/ope-edit.svg';
import IconOpeDelete from '@/assets/svg/operate/ope-delete.svg';
import { modelApi } from '@/api/system'
import { formatTimestamp } from '@/utils/date'
import { modelTypeOptions, getModelTypeName } from '@/entity/CommonEntity.ts'
const keyword = ref('')
const dialogFormVisible = ref(false)
const termFormRef = ref()