mirror of
https://gh-proxy.org/https://github.com/klinsmaya/SQLBot.git
synced 2026-11-08 01:06:59 +08:00
feat: Q&A Chat
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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] = []
|
||||
@@ -3,4 +3,5 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class ChatQuestion(BaseModel):
|
||||
question: str
|
||||
question: str
|
||||
chat_id: int
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/ {2}/g, ' ')
|
||||
}
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/ {2}/g, ' ')
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user