Merge remote-tracking branch 'origin/main'

# Conflicts:
#	frontend/components.d.ts
This commit is contained in:
wangjiahao
2025-05-27 18:35:40 +08:00
20 changed files with 1171 additions and 300 deletions
@@ -0,0 +1,52 @@
"""006_add_chat
Revision ID: ff653d5df198
Revises: 0a6f11be9be4
Create Date: 2025-05-21 15:44:29.763091
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'ff653d5df198'
down_revision = 'e6276ddab06e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('chat',
sa.Column('id', sa.Integer(), sa.Identity(always=True), nullable=False),
sa.Column('create_time', sa.DateTime(timezone=True), nullable=True),
sa.Column('create_by', sa.BigInteger(), nullable=True),
sa.Column('brief', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
sa.Column('chat_type', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
sa.Column('datasource', sa.Integer(), nullable=False),
sa.Column('engine_type', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('chat_record',
sa.Column('id', sa.Integer(), sa.Identity(always=True), nullable=False),
sa.Column('chat_id', sa.Integer(), nullable=True),
sa.Column('create_time', sa.DateTime(timezone=True), nullable=True),
sa.Column('create_by', sa.BigInteger(), nullable=True),
sa.Column('datasource', sa.Integer(), nullable=False),
sa.Column('engine_type', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
sa.Column('question', sa.Text(), nullable=True),
sa.Column('full_question', sa.Text(), nullable=True),
sa.Column('answer', sa.Text(), nullable=True),
sa.Column('run_time', sa.Float(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('chat_record')
op.drop_table('chat')
# ### end Alembic commands ###
+73 -17
View File
@@ -2,11 +2,13 @@ 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.curd.chat import list_chats, get_chat_with_records, create_chat, save_question, save_answer, rename_chat, \
delete_chat
from apps.chat.models.chat_model import CreateChat, ChatRecord, RenameChat, Chat
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.crud.datasource import get_table_obj_by_ds
from apps.datasource.models.datasource import CoreDatasource
from apps.system.models.system_model import AiModelDetail
from common.core.deps import SessionDep, CurrentUser
@@ -32,6 +34,28 @@ async def list_chat(session: SessionDep, current_user: CurrentUser, chart_id: in
)
@router.post("/rename")
async def rename(session: SessionDep, chat: RenameChat):
try:
return rename_chat(session=session, rename_object=chat)
except Exception as e:
raise HTTPException(
status_code=500,
detail=str(e)
)
@router.get("/delete/{chart_id}")
async def delete(session: SessionDep, chart_id: int):
try:
return delete_chat(session=session, chart_id=chart_id)
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:
@@ -57,26 +81,18 @@ async def stream_sql(session: SessionDep, current_user: CurrentUser, request_que
"""
question = request_question.question
# Get available AI model
aimodel = session.exec(select(AiModelDetail).where(
AiModelDetail.status == True,
AiModelDetail.api_key.is_not(None)
)).first()
# Get available datasource
ds = session.exec(select(CoreDatasource).where(
CoreDatasource.status == 'Success'
)).first()
if not aimodel:
chat = session.query(Chat).filter(Chat.id == request_question.chat_id).first()
if not chat:
raise HTTPException(
status_code=400,
detail="No available AI model configuration found"
detail=f"Chat with id {request_question.chart_id} not found"
)
# Get available datasource
ds = session.query(CoreDatasource).filter(CoreDatasource.id == chat.datasource).first()
if not ds:
raise HTTPException(
status_code=400,
status_code=500,
detail="No available datasource configuration found"
)
@@ -89,6 +105,17 @@ async def stream_sql(session: SessionDep, current_user: CurrentUser, request_que
detail=str(e1)
)
# Get available AI model
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="openai",
@@ -113,10 +140,39 @@ async def stream_sql(session: SessionDep, current_user: CurrentUser, request_que
""" result = llm_service.generate_sql(question)
return result """
# get schema
schema_str = ""
table_objs = get_table_obj_by_ds(session=session, ds=ds)
db_name = table_objs[0].schema
schema_str += f"【DB_ID】 {db_name}\n【Schema】\n"
for obj in table_objs:
schema_str += f"# Table: {db_name}.{obj.table.table_name}"
table_comment = ''
if obj.table.custom_comment:
table_comment = obj.table.custom_comment.strip()
if table_comment == '':
schema_str += '\n[\n'
else:
schema_str += f", {table_comment}\n[\n"
field_list = []
for field in obj.fields:
field_comment = ''
if field.custom_comment:
field_comment = field.custom_comment.strip()
if field_comment == '':
field_list.append(f"({field.field_name}:{field.field_type})")
else:
field_list.append(f"({field.field_name}:{field.field_type}, {field_comment})")
schema_str += ",\n".join(field_list)
schema_str += '\n]\n'
print(schema_str)
async def event_generator():
all_text = ''
try:
async for chunk in llm_service.async_generate(question):
async for chunk in llm_service.async_generate(question, schema_str):
data = json.loads(chunk.replace('data: ', ''))
if data['type'] in ['final', 'tool_result']:
+32 -4
View File
@@ -5,7 +5,7 @@ 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.models.chat_model import Chat, ChatRecord, CreateChat, ChatInfo, RenameChat
from apps.chat.schemas.chat_schema import ChatQuestion
from apps.datasource.models.datasource import CoreDatasource
from common.core.deps import SessionDep, CurrentUser
@@ -17,6 +17,32 @@ def list_chats(session: SessionDep, current_user: CurrentUser) -> List[Chat]:
return chart_list
def rename_chat(session: SessionDep, rename_object: RenameChat) -> str:
chat = session.query(Chat).filter(Chat.id == rename_object.id).first()
if not chat:
raise Exception(f"Chat with id {rename_object.id} not found")
chat.brief = rename_object.brief.strip()[:20]
session.add(chat)
session.flush()
session.refresh(chat)
brief = chat.brief
session.commit()
return brief
def delete_chat(session, chart_id) -> str:
chat = session.query(Chat).filter(Chat.id == chart_id).first()
if not chat:
return f'Chat with id {chart_id} has been deleted'
session.delete(chat)
session.commit()
return f'Chat with id {chart_id} has been deleted'
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:
@@ -33,7 +59,7 @@ def get_chat_with_records(session: SessionDep, chart_id: int, current_user: Curr
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()
and_(Chat.create_by == current_user.id, ChatRecord.chat_id == chart_id)).order_by(ChatRecord.create_time).all()
chat_info.records = record_list
@@ -98,10 +124,11 @@ def save_question(session: SessionDep, current_user: CurrentUser, question: Chat
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 = session.query(ChatRecord).filter(ChatRecord.id == id).first()
record.full_question = full_question
result = ChatRecord(**record.model_dump())
@@ -114,11 +141,12 @@ def save_full_question(session: SessionDep, id: int, full_question: str) -> Chat
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 = session.query(ChatRecord).filter(ChatRecord.id == id).first()
record.answer = answer
result = ChatRecord(**record.model_dump())
+5
View File
@@ -36,6 +36,11 @@ class CreateChat(BaseModel):
datasource: int = None
class RenameChat(BaseModel):
id: int = None
brief: str = ''
class ChatInfo(BaseModel):
id: Optional[int] = None
create_time: datetime = None
+6 -5
View File
@@ -101,16 +101,17 @@ class AgentService:
schema = self.db.get_table_info()
return chain.invoke({"schema": schema, "question": question})
async def async_generate(self, question: str) -> AsyncGenerator[str, None]:
async def async_generate(self, question: str, schema: str) -> AsyncGenerator[str, None]:
chain = self.prompt | self.agent_executor
# schema = self.db.get_table_info()
schema_engine = SchemaEngine(engine=self.db._engine)
mschema = schema_engine.mschema
mschema_str = mschema.to_mschema()
# schema_engine = SchemaEngine(engine=self.db._engine)
# mschema = schema_engine.mschema
# mschema_str = mschema.to_mschema()
async for chunk in chain.astream({"schema": mschema_str, "question": question}):
# async for chunk in chain.astream({"schema": mschema_str, "question": question}):
async for chunk in chain.astream({"schema": schema, "question": question}):
if not isinstance(chunk, dict):
continue
+7 -5
View File
@@ -211,10 +211,12 @@ def preview(session: SessionDep, id: int, data: TableObj):
return exec_sql(ds, sql)
def get_tableobj_by_ds(session: SessionDep, id: int) -> List[TableAndFields]:
list: List = []
tables = session.query(CoreTable).filter(CoreTable.ds_id == id).all()
def get_table_obj_by_ds(session: SessionDep, ds: CoreDatasource) -> List[TableAndFields]:
_list: List = []
tables = session.query(CoreTable).filter(CoreTable.ds_id == ds.id).all()
conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if ds.type != "excel" else get_engine_config()
schema = conf.dbSchema if conf.dbSchema is not None and conf.dbSchema != "" else conf.database
for table in tables:
fields = session.query(CoreField).filter(and_(CoreField.table_id == table.id, CoreField.checked == True)).all()
list.append(TableAndFields(table=table, fields=fields))
return list
_list.append(TableAndFields(schema=schema, table=table, fields=fields))
return _list
+3 -1
View File
@@ -111,9 +111,11 @@ class ColumnSchema:
class TableAndFields:
def __init__(self, table, fields):
def __init__(self, schema, table, fields):
self.schema = schema
self.table = table
self.fields = fields
schema: str
table: CoreTable
fields: List[CoreField]
+1 -1
View File
@@ -32,7 +32,7 @@ dependencies = [
"pymssql (>=2.3.4,<3.0.0)",
"pandas (>=2.2.3,<3.0.0)",
"openpyxl (>=3.1.5,<4.0.0)",
"psycopg2 (>=2.9.10,<3.0.0)",
"psycopg2-binary (>=2.9.10,<3.0.0)",
"oracledb (>=3.1.1,<4.0.0)"
]
[[tool.uv.index]]
+19 -1
View File
@@ -16,6 +16,14 @@ export const questionApi = {
query: (id: number) => request.get(`/chat/question/${id}`)
}
export interface ChatMessage {
role: 'user' | 'assistant'
create_time?: Date | string
content?: string | number
isTyping?: boolean
isWelcome?: boolean
}
export class ChatRecord {
id?: number
chat_id?: number
@@ -147,6 +155,14 @@ function startChat(data: any): Promise<ChatInfo> {
return request.post('/chat/start', data)
}
function renameChat(chat_id: number | undefined, brief: string): Promise<string> {
return request.post('/chat/rename', {id: chat_id, brief: brief})
}
function deleteChat(id: number | undefined): Promise<string> {
return request.get(`/chat/delete/${id}`)
}
export const chatApi = {
toChatRecord,
toChatRecordList,
@@ -154,5 +170,7 @@ export const chatApi = {
toChatInfoList,
list,
get,
startChat
startChat,
renameChat,
deleteChat
}
+96 -44
View File
@@ -3,59 +3,67 @@
<div class="main-menu" :class="{ 'main-menu-sidebar': !topLayout, 'main-menu-topbar': topLayout }">
<div class="logo">SQLBot</div>
<div v-if="!topLayout || !showSubmenu" :class="{ 'workspace-area': !topLayout, 'topbar-workspace-area': topLayout }">
<div v-if="!topLayout || !showSubmenu"
:class="{ 'workspace-area': !topLayout, 'topbar-workspace-area': topLayout }">
<el-select
v-model="workspace"
placeholder="Select"
class="workspace-select"
size="large"
style="width: 240px"
v-model="workspace"
placeholder="Select"
class="workspace-select"
size="large"
style="width: 240px"
>
<template #label="{ label }">
<div class="workspace-label">
<el-icon><folder /></el-icon>
<el-icon>
<folder/>
</el-icon>
<span>{{ label }}</span>
</div>
</template>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<el-menu
v-if="!topLayout || !showSubmenu"
:default-active="activeMenu"
class="menu-container"
:mode="topLayout ? 'horizontal' : 'vertical'"
v-if="!topLayout || !showSubmenu"
:default-active="activeMenu"
class="menu-container"
:mode="topLayout ? 'horizontal' : 'vertical'"
>
<el-menu-item v-for="item in routerList" :key="item.path" :index="item.path" @click="menuSelect">
<el-icon v-if="item.meta.icon">
<component :is="resolveIcon(item.meta.icon)" />
<component :is="resolveIcon(item.meta.icon)"/>
</el-icon>
<span>{{ item.meta.title }}</span>
</el-menu-item>
</el-menu>
<div v-else class="top-bar-title">
<span class="split" />
<span class="split"/>
<span>System manage</span>
</div>
<div class="main-topbar-right" v-if="topLayout">
<div class="top-back-area" v-if="showSubmenu">
<el-button type="primary" text="primary" @click="backMain">
<el-icon class="el-icon--right"><ArrowLeftBold /></el-icon>Back
<el-icon class="el-icon--right">
<ArrowLeftBold/>
</el-icon>
Back
</el-button>
</div>
<el-tooltip content="System manage" placement="bottom" v-else>
<div class="header-icon-btn" @click="toSystem">
<el-icon><iconsystem /></el-icon>
<el-icon>
<iconsystem/>
</el-icon>
<!-- <span>System manage</span> -->
</div>
</el-tooltip>
@@ -82,11 +90,13 @@
<div class="header-actions">
<el-tooltip content="System manage" placement="bottom">
<div class="header-icon-btn" @click="toSystem">
<el-icon><iconsystem /></el-icon>
<el-icon>
<iconsystem/>
</el-icon>
<span>System manage</span>
</div>
</el-tooltip>
<el-dropdown trigger="click">
<div class="user-info">
<el-avatar size="small">{{ name?.charAt(0) }}</el-avatar>
@@ -102,38 +112,38 @@
</div>
</div>
</div>
<div v-if="sysRouterList.length && showSubmenu" class="sub-menu-container">
<el-menu
:default-active="activeMenu"
class="el-menu-demo"
:mode="!topLayout ? 'horizontal' : 'vertical'"
:default-active="activeMenu"
class="el-menu-demo"
:mode="!topLayout ? 'horizontal' : 'vertical'"
>
<el-menu-item v-for="item in sysRouterList" :key="item.path" :index="item.path" @click="menuSelect">
<el-icon v-if="item.meta.icon">
<component :is="resolveIcon(item.meta.icon)" />
<component :is="resolveIcon(item.meta.icon)"/>
</el-icon>
<span>{{ item.meta.title }}</span>
</el-menu-item>
</el-menu>
</div>
<div v-if="sysRouterList.length && showSubmenu" class="sys-page-content">
<div class="sys-inner-container">
<router-view />
<router-view/>
</div>
</div>
<div v-else class="page-content">
<router-view />
<router-view/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import {ref, computed, onMounted} from 'vue'
import {useRouter, useRoute} from 'vue-router'
import {useUserStore} from '@/stores/user'
import folder from '@/assets/svg/folder.svg'
import ds from '@/assets/svg/ds.svg'
import dashboard from '@/assets/svg/dashboard.svg'
@@ -142,9 +152,10 @@ import iconsetting from '@/assets/svg/setting.svg'
import iconsystem from '@/assets/svg/system.svg'
import icon_user from '@/assets/svg/icon_user.svg'
import icon_ai from '@/assets/svg/icon_ai.svg'
import { ArrowLeftBold } from '@element-plus/icons-vue'
import { useCache } from '@/utils/useCache'
const { wsCache } = useCache()
import {ArrowLeftBold} from '@element-plus/icons-vue'
import {useCache} from '@/utils/useCache'
const {wsCache} = useCache()
const topLayout = ref(false)
const router = useRouter()
const route = useRoute()
@@ -166,9 +177,9 @@ const showSubmenu = computed(() => {
})
const workspace = ref('1')
const options = [
{ value: '1', label: 'Default workspace' },
{ value: '2', label: 'Workspace 2' },
{ value: '3', label: 'Workspace 3' }
{value: '1', label: 'Default workspace'},
{value: '2', label: 'Workspace 2'},
{value: '3', label: 'Workspace 3'}
]
const currentPageTitle = computed(() => {
if (route.path.includes('/system')) {
@@ -214,26 +225,33 @@ onMounted(() => {
.app-topbar-container {
flex-direction: column;
}
.app-container {
display: flex;
height: 100vh;
.main-menu {
display: flex;
.workspace-area {
margin: 8px 16px;
width: 208px;
overflow: hidden;
.workspace-select {
width: 100% !important;
:deep(.ed-select__wrapper) {
border-radius: 10px;
box-shadow: none !important;
background-color: #f1f3f4;
line-height: 32px;
min-height: 48px;
.ed-select__selected-item {
height: 32px;
}
.workspace-label {
color: #2d2e31;
font-weight: 600;
@@ -245,6 +263,7 @@ onMounted(() => {
}
}
}
.logo {
height: 68px;
line-height: 68px;
@@ -261,6 +280,7 @@ onMounted(() => {
border-bottom: none;
}
}
.main-menu-sidebar {
width: 240px;
background: #fff;
@@ -268,6 +288,7 @@ onMounted(() => {
display: flex;
flex-direction: column;
}
.main-menu-topbar {
height: 60px;
line-height: 60px;
@@ -278,6 +299,7 @@ onMounted(() => {
justify-content: space-between;
border-bottom: 1px solid var(--el-menu-border-color);
text-align: center;
.logo {
height: 60px;
line-height: 60px;
@@ -288,6 +310,7 @@ onMounted(() => {
height: 60px;
align-items: center;
padding-right: 24px;
.header-icon-btn {
display: flex;
column-gap: 12px;
@@ -300,42 +323,51 @@ onMounted(() => {
transition: all 0.3s;
font-size: 14px;
color: #5f6368;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
:deep(.user-info) {
display: flex;
column-gap: 4px;
align-items: center;
.ed-avatar {
background-color: var(--el-color-primary);
color: #fff;
}
.user-name {
font-size: 14px;
font-weight: 500;
color: #202124;
}
}
.top-back-area {
align-items: center;
display: flex;
}
}
.topbar-workspace-area {
margin: 0 32px;
height: auto;
width: 208px;
line-height: 54px;
.workspace-select {
width: 100% !important;
:deep(.ed-select__wrapper) {
border-radius: 10px;
box-shadow: none !important;
background-color: #f1f3f4;
line-height: 24px;
min-height: 32px;
min-height: 32px;
.workspace-label {
color: #2d2e31;
font-weight: 600;
@@ -347,6 +379,7 @@ onMounted(() => {
}
}
}
.top-bar-title {
font-size: 14px;
color: var(--el-color-info);
@@ -355,6 +388,7 @@ onMounted(() => {
left: 132px;
width: 200px;
position: fixed;
.split {
color: #bbbbbb;
border: 0.5px solid;
@@ -366,36 +400,42 @@ onMounted(() => {
.main-content {
width: calc(100% - 288px);
height: 100vh;
flex: 1;
display: flex;
flex-direction: column;
background-color: #f5f7fa;
padding: 24px;
box-sizing: border-box;
.header-container {
box-sizing: border-box;
margin: 0;
padding: 0;
height: 60px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
.header {
height: 36px;
line-height: 36px;
color: #202124;
h1 {
font-size: 24px;
font-weight: 500;
height: 36px;
line-height: 36px;
}
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.header-actions {
display: flex;
height: 36px;
align-items: center;
.header-icon-btn {
display: flex;
column-gap: 12px;
@@ -408,18 +448,22 @@ onMounted(() => {
transition: all 0.3s;
font-size: 14px;
color: #5f6368;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
:deep(.user-info) {
display: flex;
column-gap: 4px;
align-items: center;
.ed-avatar {
background-color: var(--el-color-primary);
color: #fff;
}
.user-name {
font-size: 14px;
font-weight: 500;
@@ -429,12 +473,13 @@ onMounted(() => {
}
}
}
.page-content {
flex: 1;
overflow-y: auto;
}
.sys-page-content {
background-color: var(--white);
border-radius: var(--border-radius);
@@ -442,6 +487,7 @@ onMounted(() => {
box-shadow: var(--shadow);
margin-top: 24px;
flex: 1;
.sys-inner-container {
background: #fff;
border-radius: 8px;
@@ -449,16 +495,20 @@ onMounted(() => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.sub-menu-container {
overflow: hidden;
border-radius: 8px;
}
}
.main-conntent-withbar {
padding: 0;
height: 0;
flex: 1;
width: 100%;
display: flex;
flex-direction: row;
.sub-menu-container {
flex: 0 0 auto;
background-color: lightblue;
@@ -467,10 +517,12 @@ onMounted(() => {
border-right: 1px solid var(--el-menu-border-color);
border-radius: 0;
background-color: var(--white);
:deep(.ed-menu) {
border: none;
}
}
.sys-page-content {
margin: 0;
border-radius: 0;
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type {ChatMessage} from "@/api/chat.ts";
defineProps<{
msg?: ChatMessage
}>()
</script>
<template>
<div class="chat-block">
<slot>
<div v-html="msg?.content"></div>
</slot>
</div>
</template>
<style scoped lang="less">
.chat-block {
border-radius: 2px;
background-color: white;
padding: 12px;
word-wrap: break-word;
white-space: pre-wrap;
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import {MoreFilled, EditPen, Delete} from '@element-plus/icons-vue'
import {type Chat, chatApi} from "@/api/chat.ts";
import {computed} from "vue";
const props = withDefaults(defineProps<{
currentChatId?: number
chatList: Array<Chat>
loading?: boolean
}>(), {
chatList: () => [],
loading: false
})
const emits = defineEmits(['chatSelected', 'chatRenamed', 'chatDeleted', "update:loading"])
const _loading = computed({
get() {
return props.loading
},
set(v) {
emits("update:loading", v)
}
})
function onClickHistory(chat: Chat) {
emits("chatSelected", chat)
}
function handleCommand(command: string | number | object, chat: Chat) {
if (chat && chat.id !== undefined) {
switch (command) {
case "rename":
ElMessageBox.prompt('Please enter new brief', 'Rename', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
customStyle: {'padding-left': 'unset', padding: 'var(--ed-messagebox-padding-primary)'},
inputValue: chat.brief,
inputValidator: (value: string) => {
if (!value) return false
if (value.trim().length == 0) return false
return true
}
})
.then(({value}) => {
_loading.value = true
chatApi.renameChat(chat.id, value)
.then((res) => {
ElMessage({
type: 'success',
message: 'Successfully renamed chat',
})
emits('chatRenamed', {id: chat.id, brief: res})
})
.catch((err) => {
ElMessage({
type: 'error',
message: err.message,
})
console.error(err)
})
.finally(() => {
_loading.value = false
})
})
break
case "delete":
ElMessageBox.confirm(
'This action will permanently delete the chat. Continue?',
'Warning',
{
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning',
}
)
.then(() => {
_loading.value = true
chatApi.deleteChat(chat.id)
.then((res) => {
ElMessage({
type: 'success',
message: res,
})
emits('chatDeleted', chat.id)
})
.catch((err) => {
ElMessage({
type: 'error',
message: err.message,
})
console.error(err)
})
.finally(() => {
_loading.value = false
})
})
break
}
}
}
</script>
<template>
<el-scrollbar>
<div class="chat-list-inner">
<template v-for="chat in chatList" :key="chat.id">
<div class="chat-list-item" :class="{'active': currentChatId === chat.id}" @click="onClickHistory(chat)">
<span class="title">{{ chat.brief ?? 'Untitled' }}</span>
<el-button class="icon-more" link>
<el-dropdown trigger="click" @command="(cmd: any)=> handleCommand(cmd, chat)">
<el-icon>
<MoreFilled/>
</el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :icon="EditPen" command="rename">Rename</el-dropdown-item>
<el-dropdown-item :icon="Delete" command="delete">Delete</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-button>
</div>
</template>
</div>
</el-scrollbar>
</template>
<style scoped lang="less">
.chat-list-inner {
--hover-color: var(--ed-color-primary-light-9);
--active-color: var(--hover-color);
padding-left: 14px;
padding-right: 14px;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
.chat-list-item {
width: 100%;
height: 42px;
cursor: pointer;
border-radius: 6px;
line-height: 1em;
display: flex;
align-items: center;
padding: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.title {
flex: 1;
}
.icon-more {
margin-left: auto;
display: none;
min-width: unset;
}
&:hover {
background-color: var(--hover-color);
.icon-more {
display: inline-flex;
}
}
&.active {
background-color: var(--active-color);
}
}
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import ChatBlock from './ChatBlock.vue'
import WelcomeBlock from './WelcomeBlock.vue'
import {ChatInfo, type ChatMessage} from "@/api/chat.ts";
import {computed} from "vue";
const props = withDefaults(defineProps<{
msg: ChatMessage
currentChat: ChatInfo
datasource?: number
}>(),
{}
)
const emits = defineEmits(["update:datasource"])
const _datasource = computed({
get() {
return props.datasource
},
set(v) {
emits("update:datasource", v)
}
})
</script>
<template>
<div class="chat-row" :class="{'right-to-left': msg.role === 'user'}">
<el-avatar shape="square" v-if="msg.role === 'assistant'">SQBot</el-avatar>
<el-avatar shape="square" v-if="msg.role === 'user'"/>
<ChatBlock v-if="!msg.isWelcome" :msg="msg" :class="{'row-full': msg.role === 'assistant'}">
<slot></slot>
</ChatBlock>
<WelcomeBlock v-else v-model="_datasource" :current-chat="currentChat" class="row-full"/>
</div>
</template>
<style scoped lang="less">
.chat-row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
padding: 20px 20px 0;
&.right-to-left {
flex-direction: row-reverse;
}
.row-full {
flex: 1;
}
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import ChatBlock from './ChatBlock.vue'
import {ChatInfo} from "@/api/chat.ts";
import {computed, onMounted, ref} from "vue";
import {datasourceApi} from "@/api/datasource.ts";
import DatasourceItemCard from '../ds/DatasourceItemCard.vue'
const props = withDefaults(defineProps<{
modelValue?: number
currentChat: ChatInfo
}>(),
{}
)
const dsList = ref<Array<any>>([])
const editable = computed(() => {
return props.currentChat?.id === undefined
})
const ds = computed(() => {
for (let i = 0; i < dsList.value.length; i++) {
const item = dsList.value[i]
if (props.modelValue === item.id) {
return item
}
}
if (props.currentChat && props.currentChat.datasource !== undefined) {
return {
id: props.currentChat.datasource,
name: props.currentChat.datasource_name ?? 'Datasource does not exist',
type: props.currentChat.engine_type
}
}
return undefined
})
const emits = defineEmits(["update:modelValue"])
function selectDs(ds: any) {
if (editable.value) {
emits("update:modelValue", ds.id)
}
}
function listDs() {
datasourceApi.list().then((res) => {
dsList.value = res
})
}
function showDs() {
listDs()
}
onMounted(() => {
listDs()
})
</script>
<template>
<ChatBlock>
<div>你好,我是SQLBot,很高兴为你服务</div>
<div class="sub">我可以帮忙查询数据、生成图表、检测数据异常、预测数据等,请选择一个数据源,开启智能问数吧~</div>
<template v-if="editable">
<template v-if="dsList.length>0">
<div class="ds-select-row">
<div>选择数据源</div>
<el-button @click="showDs" link type="primary">查看更多</el-button>
</div>
<div class="ds-row-container">
<template v-for="(item, _index) in dsList" :key="_index">
<DatasourceItemCard :ds="item" @click="selectDs(item)" v-if="_index<3 || item?.id===modelValue"
class="ds-card" :class="[item?.id===modelValue? 'ds-card-selected': '']"/>
</template>
</div>
</template>
<div v-else>
数据源为空,请新建后再开启智能问数!
</div>
</template>
<template v-else>
<div class="ds-select-row">
<div>已选择数据源</div>
</div>
<div class="ds-row-container">
<DatasourceItemCard :ds="ds"/>
</div>
</template>
</ChatBlock>
</template>
<style scoped lang="less">
.sub {
color: grey;
font-size: 0.8em;
}
.ds-select-row {
display: flex;
align-items: center;
flex-direction: row;
justify-content: space-between;
}
.ds-row-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
.ds-card {
cursor: pointer;
}
.ds-card-selected {
border-color: var(--ed-color-primary-light-5);
}
</style>
+94 -59
View File
@@ -11,50 +11,42 @@
</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>
<ChatList :current-chat-id="currentChatId" v-model:loading="loading" :chat-list="chatList"
@chat-selected="onClickHistory" @chat-deleted="onChatDeleted" @chat-renamed="onChatRenamed"/>
</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>
<el-main class="chat-record-list">
<el-scrollbar>
<template v-for="(message, _index) in computedMessages" :key="_index">
<ChatRow :current-chat="currentChat" v-model:datasource="currentChat.datasource" :msg="message"/>
</template>
</el-scrollbar>
</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>
<el-footer class="chat-footer">
<div style="height: 24px;">
<template v-if="currentChat.datasource">
使用数据源:{{ currentChat.datasource_name }}
</template>
</div>
<div class="input-wrapper">
<el-input
:disabled="isTyping"
class="input-area"
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"
/>
<el-button link type="primary" class="input-icon" @click="sendMessage" :disabled="isTyping">
<el-icon size="20">
<Position/>
</el-icon>
</el-button>
</div>
</el-footer>
</el-container>
@@ -65,22 +57,14 @@
<script setup lang="ts">
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'
create_time?: Date | string
content?: string | number
isTyping?: boolean
isWelcome?: boolean
}
import {Chat, chatApi, ChatInfo, type ChatMessage, ChatRecord, questionApi} from '@/api/chat'
import ChatList from './ChatList.vue'
import ChatRow from './ChatRow.vue'
const inputMessage = ref('')
const loading = ref<boolean>(false);
const chatList = ref<Array<ChatInfo>>([])
const dsList = ref<any>([])
const currentChatId = ref<number | undefined>()
const currentChat = ref<ChatInfo>(new ChatInfo())
@@ -132,7 +116,7 @@ const computedMessages = computed<Array<ChatMessage>>(() => {
const createNewChat = () => {
currentChat.value = new ChatInfo()
currentChatId.value = undefined
listDs()
inputMessage.value = ''
}
function getChatList() {
@@ -164,16 +148,29 @@ function onClickHistory(chat: Chat) {
}
}
function listDs() {
datasourceApi.list().then((res) => {
console.log(res)
dsList.value = res
})
function onChatDeleted(id: number) {
for (let i = 0; i < chatList.value.length; i++) {
if (chatList.value[i].id === id) {
chatList.value.splice(i, 1)
return
}
}
}
function onChatRenamed(chat: Chat) {
chatList.value.forEach((c: Chat) => {
if (c.id === chat.id) {
c.brief = chat.brief
}
})
if (currentChat.value.id === chat.id) {
currentChat.value.brief = chat.brief
}
}
onMounted(() => {
getChatList()
listDs()
})
@@ -197,6 +194,7 @@ const sendMessage = async () => {
currentRecord.answer = ''
currentChat.value.records.push(currentRecord)
inputMessage.value = ''
let error = false
if (currentChatId.value === undefined) {
@@ -221,7 +219,6 @@ const sendMessage = async () => {
loading.value = false
})
}
if (error) return
try {
@@ -310,6 +307,8 @@ const handleCtrlEnter = (e: KeyboardEvent) => {
border-right: solid 1px rgba(0, 0, 0, 0.3);
background: var(--ed-fill-color-blank);
.chat-container-right-container {
height: 100%;
@@ -320,12 +319,48 @@ const handleCtrlEnter = (e: KeyboardEvent) => {
}
.chat-list {
padding-top: unset;
padding: 0 0 20px 0;
}
}
}
.chat-record-list {
padding: 0 0 20px 0;
}
.chat-footer {
--ed-footer-height: 120px;
display: flex;
flex-direction: column;
.input-wrapper {
flex: 1;
position: relative;
.input-area {
height: 100%;
padding-bottom: 8px;
:deep(.ed-textarea__inner) {
height: 100% !important;
}
}
.input-icon {
min-width: unset;
position: absolute;
bottom: 14px;
right: 8px;
}
}
}
}
</style>
@@ -0,0 +1,148 @@
<script setup lang="ts">
import {datetimeFormat} from "@/utils/utils.ts";
import SQLServerDs from "@/assets/svg/ds/sqlServer-ds.svg";
import ExcelDs from "@/assets/svg/ds/Excel-ds.svg";
import PgDs from "@/assets/svg/ds/pg-ds.svg";
import MysqlDs from "@/assets/svg/ds/mysql-ds.svg";
import OracleDs from "@/assets/svg/ds/oracle-ds.svg";
defineProps<{
ds: any
}>()
const getStatus = (status: string) => {
if (status === 'Success') {
return 'connected'
}
if (status === 'Fail') {
return 'failed'
}
if (status === 'Checking') {
return 'needs-verification'
}
}
</script>
<template>
<div
class="connection-card"
>
<div class="connection-icon">
<Icon>
<MysqlDs v-if="ds.type === 'mysql'"/>
<SQLServerDs v-else-if="ds.type === 'sqlServer'"/>
<PgDs v-else-if="ds.type === 'pg'"/>
<ExcelDs v-else-if="ds.type === 'excel'"/>
<OracleDs v-else-if="ds.type === 'oracle'"/>
</Icon>
</div>
<div class="connection-details">
<div class="connection-name">{{ ds.name }}</div>
<div class="connection-type">{{ ds.type_name }}</div>
<div class="connection-host">{{ ds.description }}</div>
<div class="connection-last">{{ datetimeFormat(ds.create_time) }}</div>
</div>
<div class="connection-status" :class="`${getStatus(ds.status)}`">{{ ds.status }}</div>
<slot></slot>
</div>
</template>
<style scoped lang="less">
.connection-card {
background-color: white;
border-radius: 16px;
padding: 24px 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
display: flex;
position: relative;
border: 1px solid #dadce0;
transition: all 0.2s ease;
align-items: center;
}
.connection-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background-color: #e8f0fe;
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
flex-shrink: 0;
}
.connection-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
.connection-name {
font-weight: 600;
font-size: 18px;
color: #202124;
margin-bottom: 8px;
line-height: 1.3;
display: flex;
}
.connection-type {
color: #5f6368;
margin-bottom: 8px;
font-size: 14px;
display: flex;
}
.connection-host {
color: #5f6368;
margin-bottom: 8px;
font-size: 14px;
display: flex;
align-items: center;
}
.connection-last {
color: #5f6368;
font-size: 14px;
margin-bottom: 0;
display: flex;
align-items: center;
}
}
.connection-status {
position: absolute;
right: 20px;
top: 18px;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
max-width: 50px;
text-align: center;
justify-content: center;
opacity: 0.9;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.connection-status.connected {
background-color: #e6f4ea;
color: #34a853;
}
.connection-status.failed {
background-color: #fce8e6;
color: #ea4335;
}
.connection-status.needs-verification {
background-color: #fef7e0;
color: #fbbc05;
}
</style>
+7 -3
View File
@@ -1,16 +1,16 @@
<template>
<el-dialog
v-model="dialogVisible"
title="Add Datasource"
:title="title"
width="600"
:destroy-on-close="true"
:close-on-click-modal="false"
@closed="close"
modal-class="add-datasource_dialog"
>
<template #header="{ close, titleId, titleClass }">
<template #header="{ title }">
<div style="display: flex">
<div style="margin-right: 24px">Add Datasource</div>
<div style="margin-right: 24px">{{ title }}</div>
<el-steps
v-show="isCreate"
:active="active"
@@ -180,6 +180,7 @@ const excelUploadSuccess = ref(false);
const tableListLoading = ref(true);
const token = wsCache.get("user.token");
const headers = ref<any>({ "X-SQLBOT-TOKEN": `Bearer ${token}` });
const title = ref("");
const rules = reactive<FormRules>({
name: [
@@ -222,6 +223,7 @@ const close = () => {
const open = (item: any, editTable: boolean = false) => {
isEditTable.value = false;
if (item) {
title.value = "Edit Datasource";
isCreate.value = false;
form.value.id = item.id;
form.value.name = item.name;
@@ -243,6 +245,7 @@ const open = (item: any, editTable: boolean = false) => {
}
if (editTable) {
title.value = "Choose Tables";
active.value = 1;
isEditTable.value = true;
isCreate.value = false;
@@ -275,6 +278,7 @@ const open = (item: any, editTable: boolean = false) => {
});
}
} else {
title.value = "Add Datasource";
isCreate.value = true;
isEditTable.value = false;
checkList.value = [];
+50 -160
View File
@@ -3,15 +3,17 @@
<div class="header">
<div class="mt-4">
<el-input
v-model="searchValue"
style="max-width: 300px"
placeholder="Search Datasource..."
class="input-with-select"
clearable
@change="searchHandle"
v-model="searchValue"
style="max-width: 300px"
placeholder="Search Datasource..."
class="input-with-select"
clearable
@change="searchHandle"
>
<template #prepend>
<el-icon><Search /></el-icon>
<el-icon>
<Search/>
</el-icon>
</template>
</el-input>
</div>
@@ -20,33 +22,17 @@
</div>
<div class="connections-container">
<div
class="connection-card"
v-for="ds in dsList"
>
<div class="connection-icon">
<Icon>
<MysqlDs v-if="ds.type === 'mysql'"/>
<SQLServerDs v-else-if="ds.type === 'sqlServer'"/>
<PgDs v-else-if="ds.type === 'pg'"/>
<ExcelDs v-else-if="ds.type === 'excel'"/>
<OracleDs v-else-if="ds.type === 'oracle'"/>
</Icon>
</div>
<div class="connection-details">
<div class="connection-name">{{ ds.name }}</div>
<div class="connection-type">{{ ds.type_name }}</div>
<div class="connection-host">{{ ds.description }}</div>
<div class="connection-last">{{ datetimeFormat(ds.create_time) }}</div>
</div>
<div class="connection-status" :class="`${getStatus(ds.status)}`">{{ ds.status }}</div>
<div class="connection-actions">
<el-button class="action-btn" circle @click="getTables(ds.id, ds.name)" :icon="List" />
<el-button class="action-btn" circle @click="editTables(ds)" :icon="CreditCard" />
<el-button v-if="ds.type !== 'excel'" type="primary" class="action-btn" circle @click="editDs(ds)" :icon="IconOpeEdit"/>
<el-button type="danger" class="action-btn" circle @click="deleteDs(ds)" :icon="IconOpeDelete"/>
</div>
</div>
<template v-for="ds in dsList">
<DatasourceItemCard :ds="ds">
<div class="connection-actions">
<el-button class="action-btn" circle @click="getTables(ds.id, ds.name)" :icon="List"/>
<el-button class="action-btn" circle @click="editTables(ds)" :icon="CreditCard"/>
<el-button v-if="ds.type !== 'excel'" type="primary" class="action-btn" circle @click="editDs(ds)"
:icon="IconOpeEdit"/>
<el-button type="danger" class="action-btn" circle @click="deleteDs(ds)" :icon="IconOpeDelete"/>
</div>
</DatasourceItemCard>
</template>
</div>
</div>
<DsForm ref="dsForm" @refresh="refresh"/>
@@ -55,18 +41,13 @@
import IconOpeAdd from '@/assets/svg/operate/ope-add.svg'
import IconOpeEdit from '@/assets/svg/operate/ope-edit.svg'
import IconOpeDelete from '@/assets/svg/operate/ope-delete.svg'
import MysqlDs from '@/assets/svg/ds/mysql-ds.svg'
import SQLServerDs from '@/assets/svg/ds/sqlServer-ds.svg'
import PgDs from '@/assets/svg/ds/pg-ds.svg'
import ExcelDs from '@/assets/svg/ds/Excel-ds.svg'
import OracleDs from '@/assets/svg/ds/oracle-ds.svg'
import { Search, List, CreditCard } from '@element-plus/icons-vue'
import { ref, onMounted } from 'vue'
import {Search, List, CreditCard} from '@element-plus/icons-vue'
import {ref, onMounted} from 'vue'
import DsForm from './form.vue'
import { datasourceApi } from '@/api/datasource'
import { datetimeFormat } from '@/utils/utils'
import { ElMessageBox } from 'element-plus-secondary'
import { useRouter } from 'vue-router'
import {datasourceApi} from '@/api/datasource'
import {ElMessageBox} from 'element-plus-secondary'
import {useRouter} from 'vue-router'
import DatasourceItemCard from "@/views/ds/DatasourceItemCard.vue";
const searchValue = ref<string>('')
const dsForm = ref()
@@ -75,21 +56,12 @@ const allDsList = ref<any>([])// all ds list
const router = useRouter()
const loading = ref(false)
const getStatus = (status: string) => {
if (status === 'Success') {
return 'connected'
}
if (status === 'Fail') {
return 'failed'
}
if (status === 'Checking') {
return 'needs-verification'
}
}
function searchHandle() {
if(searchValue.value) {
dsList.value = JSON.parse(JSON.stringify(allDsList.value)).filter((item: any) => {return item.name.toLowerCase().includes(searchValue.value.toLowerCase())})
if (searchValue.value) {
dsList.value = JSON.parse(JSON.stringify(allDsList.value)).filter((item: any) => {
return item.name.toLowerCase().includes(searchValue.value.toLowerCase())
})
} else {
dsList.value = JSON.parse(JSON.stringify(allDsList.value))
}
@@ -118,22 +90,22 @@ const editTables = (item: any) => {
const deleteDs = (item: any) => {
ElMessageBox.confirm(
'Delete this datasource?',
'Delete',
{
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'warning',
}
'Delete this datasource?',
'Delete',
{
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'warning',
}
)
.then(() => {
datasourceApi.delete(item.id).then(() => {
console.log('success')
list()
})
})
.catch(() => {
})
.then(() => {
datasourceApi.delete(item.id).then(() => {
console.log('success')
list()
})
})
.catch(() => {
})
}
const getTables = (id: number, name: string) => {
@@ -157,7 +129,7 @@ onMounted(() => {
</script>
<style lang="less" scoped>
.header{
.header {
background-color: white;
padding: 16px 20px;
border-radius: 8px;
@@ -171,102 +143,20 @@ onMounted(() => {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 24px;
.connection-card {
background-color: white;
border-radius: 16px;
padding: 24px 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
display: flex;
position: relative;
border: 1px solid #dadce0;
transition: all 0.2s ease;
align-items: center;
}
.connection-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background-color: #e8f0fe;
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
flex-shrink: 0;
}
.connection-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
.connection-name {
font-weight: 600;
font-size: 18px;
color: #202124;
margin-bottom: 8px;
line-height: 1.3;
display: flex;
}
.connection-type {
color: #5f6368;
margin-bottom: 8px;
font-size: 14px;
display: flex;
}
.connection-host {
color: #5f6368;
margin-bottom: 8px;
font-size: 14px;
display: flex;
align-items: center;
}
.connection-last {
color: #5f6368;
font-size: 14px;
margin-bottom: 0;
display: flex;
align-items: center;
}
}
.connection-status {
position: absolute;
right: 20px;
top: 18px;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
max-width: 50px;
text-align: center;
justify-content: center;
opacity: 0.9;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.connection-status.connected {
background-color: #e6f4ea;
color: #34a853;
}
.connection-status.failed {
background-color: #fce8e6;
color: #ea4335;
}
.connection-status.needs-verification {
background-color: #fef7e0;
color: #fbbc05;
}
.connection-actions {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
.action-btn {
display: none;
min-width: 0;
}
}
:hover .action-btn{
:hover .action-btn {
display: flex;
}
}
@@ -0,0 +1,52 @@
<script lang="ts" setup>
import { Document } from "@element-plus/icons-vue";
withDefaults(
defineProps<{
name: string;
description: string;
creator: string;
}>(),
{
name: "",
description: "",
creator: "",
}
);
</script>
<template>
<div class="datasource-card">
<div class="description">
<el-icon size="20" style="margin-right: 12px">
<Document></Document>
</el-icon>
<span class="value">{{ name }}</span>
</div>
<div class="description">
<span class="key">describe:</span>
<span class="value">{{ description }}</span>
</div>
<div class="description">
<span class="key">Founder:</span>
<span class="value">{{ creator }}</span>
</div>
</div>
</template>
<style lang="less" scoped>
.datasource-card {
height: 120px;
padding: 16px;
border-radius: 4px;
box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.12);
.description {
display: flex;
align-items: center;
width: 100%;
margin-bottom: 8px;
.key {
margin-right: 12px;
}
}
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts" setup>
import { ref, computed } from "vue";
import DatasourceCard from "./DatasourceCard.vue";
interface Datasource {
id?: string;
name: string;
description: string;
creator: string;
}
const props = withDefaults(
defineProps<{
datasourceList: Array<Datasource>;
}>(),
{
datasourceList: () => [],
}
);
const datasourceName = ref("");
const datasourceListComputed = computed(() =>
props.datasourceList.filter((val) =>
val.name.toLowerCase().includes(datasourceName.value.toLowerCase())
)
);
const dialogVisible = ref(false);
</script>
<template>
<div class="chat-init_tip">
<div class="hello-sqlbot">Hello, I'm SQLBot, happy to serve you!</div>
<div class="function-sqlbot">
I can help you query data, generate charts, detect data anomalies, predict
data, etc. Please select a data source and start intelligent data query~
</div>
<div class="select-datasource">
<span class="title">Select data source</span>
<el-button @click="dialogVisible = true" text>View more</el-button>
</div>
<div class="datasource-content">
<DatasourceCard
:name="ele.name"
:description="ele.description"
:creator="ele.creator"
v-for="ele in datasourceList"
:key="ele.id"
></DatasourceCard>
</div>
<el-button type="primary">Create a new data source</el-button>
</div>
<el-dialog
v-model="dialogVisible"
title="Select data source"
width="800"
modal-class="select-datasource_dialog"
>
<div class="search-datasource">
<el-input
v-model="datasourceName"
style="width: 240px"
placeholder="Please input"
/>
</div>
<div class="datasource-content">
<DatasourceCard
:name="ele.name"
:description="ele.description"
:creator="ele.creator"
v-for="ele in datasourceListComputed"
:key="ele.id"
></DatasourceCard>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="dialogVisible = false">
Confirm
</el-button>
</div>
</template>
</el-dialog>
</template>
<style lang="less" scoped>
.chat-init_tip {
width: 780px;
padding: 16px;
.hello-sqlbot {
margin-bottom: 12px;
font-size: 16px;
font-weight: 500;
}
.function-sqlbot {
margin-bottom: 12px;
}
.select-datasource {
display: flex;
align-items: center;
justify-content: space-between;
}
.datasource-content {
margin: 12px 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
}
</style>
<style lang="less">
.select-datasource_dialog {
.search-datasource {
text-align: right;
}
.datasource-content {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
}
</style>