feat: Q&A Chat

This commit is contained in:
ulleo
2025-05-23 13:55:23 +08:00
parent b50ee7e2c2
commit e279ae4a36
13 changed files with 671 additions and 454 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ fileConfig(config.config_file_name)
# target_metadata = None
# from apps.system.models.user import SQLModel # noqa
from apps.settings.models.setting_models import SQLModel
# from apps.settings.models.setting_models import SQLModel
from apps.chat.models.chat_model import SQLModel
from common.core.config import settings # noqa
target_metadata = SQLModel.metadata
+69 -17
View File
@@ -1,37 +1,68 @@
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from sqlmodel import select
from apps.chat.curd.chat import list_chats, get_chat_with_records, create_chat, save_question, save_answer
from apps.chat.models.chat_model import CreateChat, ChatRecord
from apps.chat.schemas.chat_base_schema import LLMConfig
from apps.chat.schemas.chat_schema import ChatQuestion
from apps.chat.schemas.llm import AgentService
from apps.datasource.models.datasource import CoreDatasource
from apps.system.models.system_model import AiModelDetail
from common.core.deps import SessionDep
from common.core.deps import SessionDep, CurrentUser
import json
import asyncio
router = APIRouter(tags=["Data Q&A"], prefix="/chat")
@router.get("/list")
async def chats(session: SessionDep, current_user: CurrentUser):
return list_chats(session, current_user)
@router.get("/get/{chart_id}")
async def list_chat(session: SessionDep, current_user: CurrentUser, chart_id: int):
try:
return get_chat_with_records(chart_id=chart_id, session=session, current_user=current_user)
except Exception as e:
raise HTTPException(
status_code=500,
detail=str(e)
)
@router.post("/start")
async def start_chat(session: SessionDep, current_user: CurrentUser, create_chat_obj: CreateChat):
try:
return create_chat(session, current_user, create_chat_obj)
except Exception as e:
raise HTTPException(
status_code=400,
detail=str(e)
)
@router.post("/question")
async def stream_sql(session: SessionDep, requestQuestion: ChatQuestion):
async def stream_sql(session: SessionDep, current_user: CurrentUser, request_question: ChatQuestion):
"""Stream SQL analysis results
Args:
session: Database session
requestQuestion: User question model
current_user: CurrentUser
request_question: User question model
Returns:
Streaming response with analysis results
"""
question = requestQuestion.question
question = request_question.question
# Get available AI model
aimodel = session.exec(select(AiModelDetail).where(
AiModelDetail.status == True,
AiModelDetail.status == True,
AiModelDetail.api_key.is_not(None)
)).first()
# Get available datasource
ds = session.exec(select(CoreDatasource).where(
CoreDatasource.status == 'Success'
@@ -42,13 +73,22 @@ async def stream_sql(session: SessionDep, requestQuestion: ChatQuestion):
status_code=400,
detail="No available AI model configuration found"
)
if not ds:
raise HTTPException(
status_code=400,
detail="No available datasource configuration found"
)
record: ChatRecord
try:
record = save_question(session=session, current_user=current_user, question=request_question)
except Exception as e1:
raise HTTPException(
status_code=400,
detail=str(e1)
)
# Use Tongyi Qianwen
tongyi_config = LLMConfig(
model_type="openai",
@@ -72,25 +112,37 @@ async def stream_sql(session: SessionDep, requestQuestion: ChatQuestion):
vllm_service = LLMService(vllm_config) """
""" result = llm_service.generate_sql(question)
return result """
async def event_generator():
all_text = ''
try:
async for chunk in llm_service.async_generate(question):
data = json.loads(chunk.replace('data: ', ''))
if data['type'] in ['final', 'tool_result']:
content = data['content']
print('-- ' + content)
all_text += content
for char in content:
yield f"data: {json.dumps({'type': 'char', 'content': char})}\n\n"
await asyncio.sleep(0.05)
await asyncio.sleep(0.05)
if 'html' in data:
yield f"data: {json.dumps({'type': 'html', 'content': data['html']})}\n\n"
else:
yield chunk
except Exception as e:
all_text = 'Exception:' + str(e)
yield f"data: {json.dumps({'type': 'error', 'content': str(e)})}\n\n"
#return EventSourceResponse(event_generator(), headers={"Content-Type": "text/event-stream"})
return StreamingResponse(event_generator(), media_type="text/event-stream")
try:
save_answer(session=session, id=record.id, answer=all_text)
except Exception as e:
raise HTTPException(
status_code=500,
detail=str(e)
)
# return EventSourceResponse(event_generator(), headers={"Content-Type": "text/event-stream"})
return StreamingResponse(event_generator(), media_type="text/event-stream")
View File
+132
View File
@@ -0,0 +1,132 @@
import datetime
import json
from typing import List
from sqlalchemy import text, and_
from sqlmodel import select
from apps.chat.models.chat_model import Chat, ChatRecord, CreateChat, ChatInfo
from apps.chat.schemas.chat_schema import ChatQuestion
from apps.datasource.models.datasource import CoreDatasource
from common.core.deps import SessionDep, CurrentUser
def list_chats(session: SessionDep, current_user: CurrentUser) -> List[Chat]:
chart_list = session.query(Chat).filter(Chat.create_by == current_user.id).order_by(
Chat.create_time.desc()).all()
return chart_list
def get_chat_with_records(session: SessionDep, chart_id: int, current_user: CurrentUser) -> ChatInfo:
chat = session.query(Chat).filter(Chat.id == chart_id).first()
if not chat:
raise Exception(f"Chat with id {chart_id} not found")
chat_info = ChatInfo(**chat.model_dump())
ds = session.query(CoreDatasource).filter(CoreDatasource.id == chat.datasource).first()
if not ds:
chat_info.datasource_exists = False
chat_info.datasource_name = 'Datasource not exist'
else:
chat_info.datasource_exists = True
chat_info.datasource_name = ds.name
record_list = session.query(ChatRecord).filter(
and_(Chat.create_by == current_user.id, ChatRecord.id == chart_id)).order_by(ChatRecord.create_time).all()
chat_info.records = record_list
return chat_info
def create_chat(session: SessionDep, current_user: CurrentUser, create_chat_obj: CreateChat) -> ChatInfo:
if not create_chat_obj.datasource:
raise Exception("Datasource cannot be None")
if not create_chat_obj.question or create_chat_obj.question.strip() == '':
raise Exception("Question cannot be Empty")
chat = Chat(create_time=datetime.datetime.now(),
create_by=current_user.id,
brief=create_chat_obj.question.strip()[:20],
datasource=create_chat_obj.datasource)
ds = session.query(CoreDatasource).filter(CoreDatasource.id == create_chat_obj.datasource).first()
if not ds:
raise Exception(f"Datasource with id {create_chat_obj.datasource} not found")
chat.engine_type = ds.type_name
chat_info = ChatInfo(**chat.model_dump())
session.add(chat)
session.flush()
session.refresh(chat)
chat_info.id = chat.id
session.commit()
return chat_info
def save_question(session: SessionDep, current_user: CurrentUser, question: ChatQuestion) -> ChatRecord:
if not question.chat_id:
raise Exception("ChatId cannot be None")
if not question.question or question.question.strip() == '':
raise Exception("Question cannot be Empty")
chat = session.query(Chat).filter(Chat.id == question.chat_id).first()
if not chat:
raise Exception(f"Chat with id {question.chat_id} not found")
record = ChatRecord()
record.question = question.question
record.chat_id = chat.id
record.create_time = datetime.datetime.now()
record.create_by = current_user.id
record.datasource = chat.datasource
record.engine_type = chat.engine_type
result = ChatRecord(**record.model_dump())
session.add(record)
session.flush()
session.refresh(record)
result.id = record.id
session.commit()
return result
def save_full_question(session: SessionDep, id: int, full_question: str) -> ChatRecord:
if not id:
raise Exception("Record id cannot be None")
record = session.query(ChatRecord).filter(Chat.id == id).first()
record.full_question = full_question
result = ChatRecord(**record.model_dump())
session.add(record)
session.flush()
session.refresh(record)
session.commit()
return result
def save_answer(session: SessionDep, id: int, answer: str) -> ChatRecord:
if not id:
raise Exception("Record id cannot be None")
record = session.query(ChatRecord).filter(Chat.id == id).first()
record.answer = answer
result = ChatRecord(**record.model_dump())
session.add(record)
session.flush()
session.refresh(record)
session.commit()
return result
+49
View File
@@ -0,0 +1,49 @@
from sqlmodel import SQLModel, Field
from sqlalchemy import Column, Text, BigInteger, DateTime, Integer, Identity
from datetime import datetime
from pydantic import BaseModel
from typing import List, Optional
class Chat(SQLModel, table=True):
__tablename__ = "chat"
id: Optional[int] = Field(sa_column=Column(Integer, Identity(always=True), primary_key=True))
create_time: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=True))
create_by: int = Field(sa_column=Column(BigInteger, nullable=True))
brief: str = Field(max_length=64, nullable=True)
chat_type: str = Field(max_length=20, default="chat") # chat, datasource
datasource: int = Field(sa_column=Column(Integer, nullable=False))
engine_type: str = Field(max_length=64)
class ChatRecord(SQLModel, table=True):
__tablename__ = "chat_record"
id: Optional[int] = Field(sa_column=Column(Integer, Identity(always=True), primary_key=True))
chat_id: int = Field(sa_column=Column(Integer))
create_time: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=True))
create_by: int = Field(sa_column=Column(BigInteger, nullable=True))
datasource: int = Field(sa_column=Column(Integer, nullable=False))
engine_type: str = Field(max_length=64)
question: str = Field(sa_column=Column(Text, nullable=True))
full_question: str = Field(sa_column=Column(Text, nullable=True))
answer: str = Field(sa_column=Column(Text, nullable=True))
run_time: float = Field(default=0)
class CreateChat(BaseModel):
id: int = None
question: str = ''
datasource: int = None
class ChatInfo(BaseModel):
id: Optional[int] = None
create_time: datetime = None
create_by: int = None
brief: str = ''
chat_type: str = "chat"
datasource: int = None
engine_type: str = ''
datasource_name: str = ''
datasource_exists: bool = True
records: List[ChatRecord] = []
+2 -1
View File
@@ -3,4 +3,5 @@ from pydantic import BaseModel
class ChatQuestion(BaseModel):
question: str
question: str
chat_id: int
+1 -1
View File
@@ -36,7 +36,7 @@ dependencies = [
"oracledb (>=3.1.1,<4.0.0)"
]
[[tool.uv.index]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
url = "https://mirrors.aliyun.com/pypi/simple"
default = true
[tool.uv]
+2 -1
View File
@@ -13,7 +13,8 @@
"snowflake-id": "^1.1.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"web-storage-cache": "^1.1.1"
"web-storage-cache": "^1.1.1",
"dayjs": "^1.11.13"
},
"devDependencies": {
"@element-plus/icons-vue": "^2.3.1",
+155 -12
View File
@@ -1,15 +1,158 @@
import { request } from '@/utils/request'
import {request} from '@/utils/request'
import {getDate} from "@/utils/utils.ts";
export const questionApi = {
pager: (pageNumber: number, pageSize: number) => request.get(`/chat/question/pager/${pageNumber}/${pageSize}`),
/* add: (data: any) => new Promise((resolve, reject) => {
request.post('/chat/question', data, { responseType: 'stream', timeout: 0, onDownloadProgress: p => {
resolve(p)
}}).catch(e => reject(e))
}), */
// add: (data: any) => request.post('/chat/question', data),
add: (data: any) => request.fetchStream('/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}`)
pager: (pageNumber: number, pageSize: number) => request.get(`/chat/question/pager/${pageNumber}/${pageSize}`),
/* add: (data: any) => new Promise((resolve, reject) => {
request.post('/chat/question', data, { responseType: 'stream', timeout: 0, onDownloadProgress: p => {
resolve(p)
}}).catch(e => reject(e))
}), */
// add: (data: any) => request.post('/chat/question', data),
add: (data: any) => request.fetchStream('/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}`)
}
export class ChatRecord {
id?: number
chat_id?: number
create_time?: Date | string
create_by?: number
datasource?: number
engine_type?: string
question?: string
answer?: string
run_time: number = 0
constructor()
constructor(id: number, chat_id: number, create_time: Date | string, create_by: number, datasource: number, engine_type: string, question: string, answer: string, run_time: number)
constructor(id?: number, chat_id?: number, create_time?: Date | string, create_by?: number, datasource?: number, engine_type?: string, question?: string, answer?: string, run_time?: number) {
this.id = id
this.chat_id = chat_id
this.create_time = getDate(create_time)
this.create_by = create_by
this.datasource = datasource
this.engine_type = engine_type
this.question = question
this.answer = answer
this.run_time = run_time ?? 0
}
}
export class Chat {
id?: number
create_time?: Date | string
create_by?: number
brief?: string
chat_type?: string
datasource?: number
engine_type?: string
constructor()
constructor(id: number, create_time: Date | string, create_by: number, brief: string, chat_type: string, datasource: number, engine_type: string)
constructor(id?: number, create_time?: Date | string, create_by?: number, brief?: string, chat_type?: string, datasource?: number, engine_type?: string) {
this.id = id
this.create_time = getDate(create_time)
this.create_by = create_by
this.brief = brief
this.chat_type = chat_type
this.datasource = datasource
this.engine_type = engine_type
}
}
export class ChatInfo extends Chat {
datasource_name?: string
datasource_exists: boolean = true
records: Array<ChatRecord> = []
constructor()
constructor(chat: Chat)
constructor(id: number, create_time: Date | string, create_by: number, brief: string, chat_type: string, datasource: number, engine_type: string, datasource_name: string, datasource_exists: boolean, records: Array<ChatRecord>)
constructor(param1?: number | Chat, create_time?: Date | string, create_by?: number, brief?: string, chat_type?: string, datasource?: number, engine_type?: string, datasource_name?: string, datasource_exists: boolean = true, records: Array<ChatRecord> = []) {
super()
if (param1 !== undefined) {
if (param1 instanceof Chat) {
this.id = param1.id
this.create_time = getDate(param1.create_time)
this.create_by = param1.create_by
this.brief = param1.brief
this.chat_type = param1.chat_type
this.datasource = param1.datasource
this.engine_type = param1.engine_type
} else {
this.id = param1
this.create_time = getDate(create_time)
this.create_by = create_by
this.brief = brief
this.chat_type = chat_type
this.datasource = datasource
this.engine_type = engine_type
}
}
this.datasource_name = datasource_name
this.datasource_exists = datasource_exists
this.records = records
}
}
function toChatRecord(data?: any): ChatRecord | undefined {
if (!data) {
return undefined
}
return new ChatRecord(data.id, data.number, data.create_time, data.create_by, data.datasource, data.engine_type, data.question, data.answer, data.run_time)
}
function toChatRecordList(list: any = []): ChatRecord[] {
const records: Array<ChatRecord> = []
for (let i = 0; i < list.length; i++) {
const record = toChatRecord(list[i])
if (record) {
records.push(record)
}
}
return records
}
function toChatInfo(data?: any): ChatInfo | undefined {
if (!data) {
return undefined
}
return new ChatInfo(data.id, data.create_time, data.create_by, data.brief, data.chat_type, data.datasource, data.engine_type, data.datasource_name, data.datasource_exists, toChatRecordList(data.records))
}
function toChatInfoList(list: any[] = []): ChatInfo[] {
const infos: Array<ChatInfo> = []
for (let i = 0; i < list.length; i++) {
const chatInfo = toChatInfo(list[i])
if (chatInfo) {
infos.push(chatInfo)
}
}
return infos
}
function list(): Promise<Array<ChatInfo>> {
return request.get('/chat/list')
}
function get(id: number): Promise<ChatInfo> {
return request.get(`/chat/get/${id}`)
}
function startChat(data: any): Promise<ChatInfo> {
return request.post('/chat/start', data)
}
export const chatApi = {
toChatRecord,
toChatRecordList,
toChatInfo,
toChatInfoList,
list,
get,
startChat
}
-3
View File
@@ -3,9 +3,6 @@
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
+31 -14
View File
@@ -1,20 +1,37 @@
import dayjs from "dayjs";
const getCheckDate = (timestamp: any) => {
if (!timestamp) return false
const dt = new Date(timestamp)
if (isNaN(dt.getTime())) return false
return dt
if (!timestamp) return false
const dt = new Date(timestamp)
if (isNaN(dt.getTime())) return false
return dt
}
export const datetimeFormat = (timestamp: any) => {
const dt = getCheckDate(timestamp)
if (!dt) return timestamp
const dt = getCheckDate(timestamp)
if (!dt) return timestamp
const y = dt.getFullYear()
const m = (dt.getMonth() + 1 + '').padStart(2, '0')
const d = (dt.getDate() + '').padStart(2, '0')
const hh = (dt.getHours() + '').padStart(2, '0')
const mm = (dt.getMinutes() + '').padStart(2, '0')
const ss = (dt.getSeconds() + '').padStart(2, '0')
const y = dt.getFullYear()
const m = (dt.getMonth() + 1 + '').padStart(2, '0')
const d = (dt.getDate() + '').padStart(2, '0')
const hh = (dt.getHours() + '').padStart(2, '0')
const mm = (dt.getMinutes() + '').padStart(2, '0')
const ss = (dt.getSeconds() + '').padStart(2, '0')
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
}
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
}
/**
*
* string: only accept ISO 8601, example: '2018-04-04T16:00:00.000Z'
* number: timestamp
* @param time
*/
export function getDate(time?: Date | string | number) {
if (!time) return undefined
if (time instanceof Date) return time
if (typeof time === 'string') {
return dayjs(time).toDate()
}
return new Date(time)
}
+228 -404
View File
@@ -1,279 +1,288 @@
<template>
<div class="chat-container">
<!-- Sidebar for chat history -->
<div class="chat-sidebar">
<div class="sidebar-header">
<el-button type="primary" class="new-chat" @click="createNewChat">
<el-icon><Plus /></el-icon>
New Conversation
</el-button>
<div class="search-box">
<el-input
v-model="searchKeyword"
placeholder="Search conversation history"
:prefix-icon="Search"
/>
<el-container class="chat-container">
<el-aside class="chat-container-left">
<el-container class="chat-container-right-container">
<el-header class="chat-list-header">
<el-button type="primary" @click="createNewChat">
<el-icon>
<Plus/>
</el-icon>
New Conversation
</el-button>
</el-header>
<el-main class="chat-list">
<el-scrollbar>
<div v-for="chat in chatList" @click="onClickHistory(chat)"
:style="chat.id === currentChatId ? {border: 'solid 1px red'} : {}">
{{ chat.create_time }}<br/>
{{ chat.brief }}
</div>
</el-scrollbar>
</el-main>
</el-container>
</el-aside>
<el-container :loading="loading">
<el-main>
<div v-for="message in computedMessages">
{{ message.role }}: {{ message.content }}
<div v-if="message.isWelcome">
<el-select v-model="currentChat.datasource" :disabled="currentChat.id!==undefined">
<el-option v-for="item in dsList"
:value="item.id"
:key="item.id"
:label="item.name"/>
</el-select>
</div>
</div>
</div>
<div class="history-list">
<div
v-for="chat in filteredHistory"
:key="chat.id"
class="history-item"
:class="{ active: currentChatId === chat.id }"
@click="switchChat(chat.id)"
>
<span class="chat-title">{{ chat.title }}</span>
<el-dropdown trigger="hover" @command="handleCommand($event, chat.id)">
<span class="more-actions">
<el-icon><More /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="rename">Rename</el-dropdown-item>
<el-dropdown-item command="delete" divided>Delete</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- Main chat area -->
<div class="chat-main">
<div class="chat-messages" ref="messagesRef">
<template v-if="currentChat?.messages?.length">
<div
v-for="(msg, index) in currentChat.messages"
:key="index"
class="message"
:class="[msg.role, { 'is-typing': msg.isTyping }]"
>
<div
v-if="msg.role === 'assistant'"
class="message-content"
:class="{ 'typing': msg.isTyping }"
v-html="formatMessage(msg.content)"
></div>
<div v-else class="message-content">
{{ msg.content }}
</el-main>
<el-footer>
<div class="chat-input">
<div class="input-wrapper">
<el-input
v-model="inputMessage"
type="textarea"
:rows="1"
:autosize="{ minRows: 1, maxRows: 8 }"
placeholder="Press Enter to send, Ctrl + Enter for new line"
@keydown.enter.exact.prevent="sendMessage"
@keydown.ctrl.enter.exact.prevent="handleCtrlEnter"
/>
<div class="input-actions">
<el-button circle type="primary" class="send-btn" @click="sendMessage">
<el-icon>
<Position/>
</el-icon>
</el-button>
</div>
</div>
</template>
<div v-else class="empty-state">
<p>Welcome to SQLBot</p>
<p class="sub-text">You can ask questions in natural language, for example:</p>
<div class="examples">
<div class="example-item">View this month's sales growth compared to last month</div>
<div class="example-item">Analyze sales staff performance by region</div>
<div class="example-item">Calculate the relationship between customer purchase frequency and average order value</div>
</div>
</div>
</div>
<div class="chat-input">
<div class="input-wrapper">
<el-input
v-model="inputMessage"
type="textarea"
:rows="1"
:autosize="{ minRows: 1, maxRows: 8 }"
placeholder="Press Enter to send, Ctrl + Enter for new line"
@keydown.enter.exact.prevent="sendMessage"
@keydown.ctrl.enter.exact.prevent="handleCtrlEnter"
/>
<div class="input-actions">
<el-button circle type="primary" class="send-btn" @click="sendMessage">
<el-icon><Position /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</el-footer>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { Plus, Search, More, Position } from '@element-plus/icons-vue'
import { questionApi } from '@/api/chat'
import {ref, computed, nextTick, watch, onMounted} from 'vue'
import {Plus, Position} from '@element-plus/icons-vue'
import {Chat, chatApi, ChatInfo, ChatRecord, questionApi} from '@/api/chat'
import {datasourceApi} from "@/api/datasource.ts";
interface ChatMessage {
role: 'user' | 'assistant'
content: string
isTyping?: boolean
create_time?: Date | string
content?: string | number
isTyping?: boolean
isWelcome?: boolean
}
interface Chat {
id: string
title: string
messages: ChatMessage[]
}
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 =>
chat.title.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
})
const loading = ref<boolean>(false);
const chatList = ref<Array<ChatInfo>>([])
const dsList = ref<any>([])
const currentChat = computed(() => {
return chatHistory.value.find(chat => chat.id === currentChatId.value)
const currentChatId = ref<number | undefined>()
const currentChat = ref<ChatInfo>(new ChatInfo())
const isTyping = ref<boolean>(false)
const computedMessages = computed<Array<ChatMessage>>(() => {
const welcome: ChatMessage = {
role: 'assistant',
create_time: currentChat.value?.create_time,
content: currentChat.value?.datasource,
isTyping: false,
isWelcome: true,
}
const messages: Array<ChatMessage> = [welcome]
if (currentChatId.value === undefined) {
return messages
}
let appendThinking = false
for (let i = 0; i < currentChat.value.records.length; i++) {
const record = currentChat.value.records[i]
if (record.question !== undefined) {
messages.push({
role: 'user',
create_time: record.create_time,
content: record.question,
})
}
if (record.answer !== undefined && record.answer !== '') {
messages.push({
role: 'assistant',
create_time: record.create_time,
content: record.answer,
isTyping: i === currentChat.value.records.length - 1 && isTyping.value
})
} else {
appendThinking = true
}
}
if (isTyping.value && appendThinking) {
messages.push({
role: 'assistant',
content: 'Thinking...',
isTyping: true
})
}
return messages
})
const createNewChat = () => {
const newChat: Chat = {
id: Date.now().toString(),
title: `New Chat ${chatHistory.value.length + 1}`,
messages: []
currentChat.value = new ChatInfo()
currentChatId.value = undefined
listDs()
}
function getChatList() {
loading.value = true
chatApi.list()
.then((res) => {
chatList.value = chatApi.toChatInfoList(res)
})
.finally(() => {
loading.value = false
})
}
function onClickHistory(chat: Chat) {
currentChat.value = new ChatInfo(chat)
if (chat !== undefined && chat.id !== undefined && !loading.value) {
currentChatId.value = chat.id
loading.value = true
chatApi.get(chat.id)
.then((res) => {
const info = chatApi.toChatInfo(res)
if (info) {
currentChat.value = info
}
})
.finally(() => {
loading.value = false
})
}
chatHistory.value.unshift(newChat)
currentChatId.value = newChat.id
}
const switchChat = (chatId: string) => {
currentChatId.value = chatId
function listDs() {
datasourceApi.list().then((res) => {
console.log(res)
dsList.value = res
})
}
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()
}
}) */
}
onMounted(() => {
getChatList()
listDs()
})
const updateMessageContent = (index: number, content: string) => {
const updateMessageContent = (content: string) => {
if (currentChat.value) {
currentChat.value.messages[index] = {
...currentChat.value.messages[index],
content
}
currentChat.value.records[currentChat.value.records.length - 1].answer = content
}
}
const sendMessage = async () => {
if (!inputMessage.value.trim()) return
let chat = currentChat.value
if (!chat) {
chat = {
id: Date.now().toString(),
title: 'New Chat',
messages: []
}
chatHistory.value.unshift(chat)
currentChatId.value = chat.id
if (computedMessages.value[0].content === undefined) return
loading.value = true
isTyping.value = true
const currentRecord = new ChatRecord()
currentRecord.create_time = new Date()
currentRecord.chat_id = currentChatId.value
currentRecord.question = inputMessage.value
currentRecord.answer = ''
currentChat.value.records.push(currentRecord)
let error = false
if (currentChatId.value === undefined) {
chatApi.startChat({question: currentRecord.question.trim(), datasource: currentChat.value.datasource})
.then((res) => {
const chat = chatApi.toChatInfo(res)
if (chat !== undefined) {
chatList.value.unshift(chat)
currentChatId.value = chat.id
chat.records.push(currentRecord)
currentChat.value = chat
} else {
error = true
}
})
.catch(e => {
isTyping.value = false
error = true
console.error(e)
})
.finally(() => {
loading.value = false
})
}
chat.messages.push({
role: 'user',
content: inputMessage.value
})
const defaultMsg = 'Thinking...'
chat.messages.push({
role: 'assistant',
content: defaultMsg,
isTyping: true
})
const userInput = inputMessage.value
inputMessage.value = ''
const index = chat.messages.length - 1
if (error) return
try {
const response = await questionApi.add({ question: userInput })
const response = await questionApi.add({question: currentRecord.question, chat_id: currentChatId.value})
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
const {done, value} = await reader.read()
if (done) {
chat.messages[index].isTyping = false
isTyping.value = false
break
}
const chunk = decoder.decode(value)
const lines = chunk.split('\n\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.replace('data:', '').trim())
let realContent = data.content
switch (data.type) {
case 'html':
realContent = '\n' + data.content
break
case 'error':
realContent = '\nError: ' + data.content
break
default:
if (data.content) {
const currentContent = chat.messages[index].content
const newContent = currentContent.startsWith(defaultMsg)
? realContent
: currentContent + realContent
updateMessageContent(index, newContent)
const newContent = currentRecord.answer + realContent
updateMessageContent(newContent)
await nextTick()
scrollToBottom()
}
}
}
}
}
} catch (error) {
updateMessageContent(currentRecord.answer + '\nError: Failed to get response')
console.error('Error:', error)
updateMessageContent(index, chat.messages[index].content + '\nError: Failed to get response')
chat.messages[index].isTyping = false
isTyping.value = false
}
}
watch(() => currentChat.value?.messages, () => {
scrollToBottom()
}, { deep: true })
watch(() => currentChat.value?.records[currentChat.value.records.length - 1]?.answer, () => {
//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) => {
switch (command) {
case 'rename':
break
case 'delete':
chatHistory.value = chatHistory.value.filter(chat => chat.id !== chatId)
if (currentChatId.value === chatId) {
currentChatId.value = chatHistory.value[0]?.id || ''
}
break
}
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
.replace(/ {2}/g, '&nbsp;&nbsp;')
}
const handleCtrlEnter = (e: KeyboardEvent) => {
@@ -281,9 +290,9 @@ const handleCtrlEnter = (e: KeyboardEvent) => {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const value = textarea.value
inputMessage.value = value.substring(0, start) + '\n' + value.substring(end)
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = start + 1
})
@@ -292,215 +301,30 @@ const handleCtrlEnter = (e: KeyboardEvent) => {
<style lang="less" scoped>
.chat-container {
height: calc(100vh - 61px);
display: flex;
background-color: var(--white);
border-radius: var(--border-radius);
box-shadow: var(--shadow);
overflow: hidden;
height: 100%;
.chat-sidebar {
width: 260px;
border-right: 1px solid #e6e6e6;
display: flex;
flex-direction: column;
.sidebar-header {
padding: 16px;
border-bottom: 1px solid #e6e6e6;
.chat-container-left {
.new-chat {
width: 100%;
margin-bottom: 12px;
}
--el-aside-width: 260px;
.search-box {
:deep(.el-input__wrapper) {
background-color: #f5f5f5;
}
}
}
border-right: solid 1px rgba(0, 0, 0, 0.3);
.history-list {
flex: 1;
overflow-y: auto;
padding: 8px;
.chat-container-right-container {
height: 100%;
.history-item {
.chat-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: 4px;
border-radius: 6px;
cursor: pointer;
&:hover {
background-color: #f5f5f5;
}
&.active {
background-color: #e6f4ff;
}
.chat-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.more-actions {
opacity: 0;
padding: 4px;
}
&:hover .more-actions {
opacity: 1;
}
justify-content: center;
}
.chat-list {
padding-top: unset;
}
}
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
overflow: hidden;
.chat-messages {
// flex: 1;
height: calc(100% - 188px);
overflow-y: auto;
padding: 24px;
scroll-behavior: smooth;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
&:hover {
background: rgba(0, 0, 0, 0.3);
}
}
&::-webkit-scrollbar-track {
background: transparent;
}
&:not(:hover)::-webkit-scrollbar-thumb {
background: transparent;
}
.message {
margin-bottom: 24px;
transition: all 0.3s ease;
&.user {
text-align: right;
.message-content {
background-color: #e6f4ff;
}
}
&.assistant {
.message-content {
background-color: #f5f5f5;
text-align: left;
}
}
.message-content {
display: inline-block;
padding: 12px 16px;
border-radius: 8px;
max-width: 80%;
transition: all 0.3s ease;
}
}
.empty-state {
text-align: center;
color: #666;
padding: 40px 0;
.sub-text {
margin: 16px 0;
}
.examples {
.example-item {
background-color: #f5f5f5;
padding: 12px;
margin: 8px auto;
max-width: 400px;
border-radius: 8px;
cursor: pointer;
&:hover {
background-color: #e6f4ff;
}
}
}
}
}
.chat-input {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1px solid var(--el-border-color-lighter);
padding: 16px 24px;
z-index: 10;
.input-wrapper {
position: relative;
border-radius: 12px;
border: 1px solid var(--el-border-color);
background: #f5f5f5;
transition: all 0.3s;
&:hover, &:focus-within {
border-color: var(--el-border-color-darker);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
:deep(.el-textarea__inner) {
padding: 16px;
font-size: 14px;
resize: none;
border: none;
background: transparent;
box-shadow: none !important;
&:focus {
box-shadow: none !important;
}
}
:deep(.el-textarea__wrapper) {
box-shadow: none !important;
padding: 0;
background: transparent;
}
.input-actions {
display: flex;
align-items: center;
justify-content: end;
height: 36px;
padding: 8px;
}
}
}
}
}
</style>