This commit is contained in:
QiyiJiang 2024-07-17 11:50:33 +00:00
commit 292803f989
18 changed files with 1653 additions and 112 deletions

View File

@ -34,9 +34,23 @@ class Config(SimpleConfig):
self.filename = filename
logger.info(f"Loading config from {filename}")
### startup
### >>> 默认配置
# 可以在 config/base.yaml 中覆盖
self.mode = "cli"
self.stream = False
self.stream = True
# 功能选项
self.enable_query_rewrite = True
self.enable_knowledge_base = True
self.enable_knowledge_graph = True
self.enable_search_engine = True
# 模型配置
## 注意这里是模型名,而不是具体的模型路径,默认使用 HuggingFace 的路径
## 如果需要自定义路径,则在 config/base.yaml 中配置 model_local_paths
self.embed_model = "bge-large-zh-v1.5"
self.reranker = "bge-reranker-v2-m3"
### <<< 默认配置结束
self.load()
self.handle_self()
@ -51,6 +65,7 @@ class Config(SimpleConfig):
def load(self):
"""根据传入的文件覆盖掉默认配置"""
if self.filename is not None and os.path.exists(self.filename):
if self.filename.endswith(".json"):
with open(self.filename, 'r') as f:

View File

@ -1,25 +1,12 @@
# 默认配置请参考 config/__init__.py
name: base
## model
### model_provider, option in deepseek, zhipu
model_provider: qianfan
# 注意这里是模型名,而不是具体的模型路径,默认使用 HuggingFace 的路径
# 如果需要自定义路径,则在下面配置 model_local_paths
embed_model: bge-large-zh # option in ["bge-large-zh"]
## startup
stream: True
## knowledge
enable_query_rewrite: True
enable_knowledge_base: True
enable_knowledge_graph: True
enable_search_engine: True
## model dir 可以写相对路径和绝对路径
### 相对路径是相对于环境变量中 MODEL_ROOT_DIR 的路径
model_local_paths:
bge-large-zh: bge-large-zh-v1.5
bge-large-zh-v1.5: bge-large-zh-v1.5
oneke: oneke

View File

@ -1,7 +1,8 @@
import os
import json
import time
from utils import hashstr, setup_logger
from utils import hashstr, setup_logger, is_text_pdf
from plugins import pdf2txt
from core.knowledgebase import KnowledgeBase
from core.filereader import pdfreader, plainreader
from core.graphbase import GraphDatabase
@ -146,18 +147,25 @@ class DataBaseManager:
support_format = [".pdf", ".txt", "*.md"]
assert os.path.exists(file), "File not found"
logger.info(f"Try to read file {file}")
if os.path.isfile(file):
if file.endswith(".pdf"):
return pdfreader(file)
elif file.endswith(".txt") or file.endswith(".md"):
return plainreader(file)
else:
logger.error(f"File format not supported, only support {support_format}")
raise Exception(f"File format not supported, only support {support_format}")
else:
if not os.path.isfile(file):
logger.error(f"Directory not supported now!")
raise NotImplementedError("Directory not supported now!")
if file.endswith(".pdf"):
if is_text_pdf(file):
return pdfreader(file)
else:
return pdf2txt(file, return_text=True)
elif file.endswith(".txt") or file.endswith(".md"):
return plainreader(file)
else:
logger.error(f"File format not supported, only support {support_format}")
raise Exception(f"File format not supported, only support {support_format}")
def delete_file(self, db_id, file_id):
db = self.get_kb_by_id(db_id)
file_idx_to_delete = [idx for idx, f in enumerate(db.files) if f["file_id"] == file_id][0]
@ -187,6 +195,16 @@ class DataBaseManager:
chunks.append(text[i:i + chunk_size])
return chunks
def delete_database(self, db_id):
db = self.get_kb_by_id(db_id)
if db is None:
return {"message": "database not found"}, 404
self.knowledge_base.client.drop_collection(db.metaname)
self.data["databases"] = [d for d in self.data["databases"] if d.db_id != db_id]
self._save_databases()
return {"message": "删除成功"}
def get_kb_by_id(self, db_id):
for db in self.data["databases"]:
if db.db_id == db_id:

View File

@ -66,8 +66,7 @@ class KnowledgeBase:
res = self.client.insert(collection_name=collection_name, data=data)
return res
def search(self, query, collection_name, limit=None):
limit = limit or self.default_query_limit
def search(self, query, collection_name, limit=3):
query_vectors = self.embed_model.encode_queries([query])

View File

@ -1,32 +1,39 @@
from core.startup import dbm, model
from models.embedding import ReRanker
class Retriever:
def __init__(self, config):
self.config = config
self.reranker = ReRanker(config)
def retrieval(self, query, history):
def retrieval(self, query, history, meta):
refs = {}
# TODO: 查询分类、查询重写、查询分解、查询伪文档生成HyDE)
# NOTE2024-07-14 暂时禁用知识检索
refs["knowledge_base"] = self.query_knowledgebase(query, history, meta)
refs["graph_base"] = self.query_graph(query, history, meta)
return refs
def construct_query(self, query, refs):
# TODOReranking
def construct_query(self, query, refs, meta):
if len(refs) == 0:
return query
external = ""
kb_res = refs.get("knowledge_base")
if kb_res:
kb_res = refs.get("knowledge_base").get("results", [])
if len(kb_res) > 0:
kb_text = "\n".join([f"{r['id']}: {r['entity']['text']}" for r in kb_res])
external += f"知识库信息: \n\n{kb_text}"
# db_res = refs.get("graph_base").get("results", [])
# if len(db_res) > 0:
# db_text = "\n".join([f"{r['id']}: {r['entity']['text']}" for r in db_res])
# external += f"图数据库信息: \n\n{db_text}"
if len(external) > 0:
query = f"以下是参考资料:\n\n\n{external}\n\n\n请根据前面的知识回答:{query}"
@ -39,7 +46,8 @@ class Retriever:
"""
raise NotImplementedError
def query_graph(self, query, history):
def query_graph(self, query, history, meta):
# res = model.predict("qiansdgsa, dasdh ashdsakjdk ak ").content
results = []
_, entities = self.rewrite_query(query, history)
@ -48,6 +56,19 @@ class Retriever:
results.append(result) if result else None
return results
def query_knowledgebase(self, query, history, meta):
kb_res = []
if meta.get("db_name"):
kb_res = dbm.knowledge_base.search(query, meta["db_name"], limit=5)
for r in kb_res:
r["rerank_score"] = self.reranker.compute_score([query, r["entity"]["text"]], normalize=True)
kb_res.sort(key=lambda x: x["rerank_score"], reverse=True)
final_res = [_res for _res in kb_res if _res["rerank_score"] > 0.1]
return {"results": final_res, "all_results": kb_res}
def rewrite_query(self, query, history):
"""重写查询"""
if history == []:
@ -87,10 +108,10 @@ class Retriever:
entity_extraction_prompt = entity_extraction_prompt_template.format(text=rewritten_query)
entities = model.predict(entity_extraction_prompt).content.split(",")
entities = [entity for entity in entities if all(char.isalnum() or char in '汉字' for char in entity)]
return rewritten_query, entities
def __call__(self, query, history):
refs = self.retrieval(query, history)
query = self.construct_query(query, refs)
def __call__(self, query, history, meta):
refs = self.retrieval(query, history, meta)
query = self.construct_query(query, refs, meta)
return query, refs

View File

@ -1,4 +1,4 @@
from FlagEmbedding import FlagModel
from FlagEmbedding import FlagModel, FlagReranker
from utils.logging_config import setup_logger
@ -6,11 +6,15 @@ from utils.logging_config import setup_logger
logger = setup_logger("EmbeddingModel")
SUPPORT_LIST = {
"bge-large-zh": "BAAI/bge-large-zh-v1.5",
"bge-large-zh-v1.5": "BAAI/bge-large-zh-v1.5",
}
RERANKER_LIST = {
"bge-reranker-v2-m3": "BAAI/bge-reranker-v2-m3",
}
QUERY_INSTRUCTION = {
"bge-large-zh": "为这个句子生成表示以用于检索相关文章:",
"bge-large-zh-v1.5": "为这个句子生成表示以用于检索相关文章:",
}
class EmbeddingModel(FlagModel):
@ -25,4 +29,16 @@ class EmbeddingModel(FlagModel):
query_instruction_for_retrieval=QUERY_INSTRUCTION[config.embed_model],
use_fp16=False, **kwargs)
logger.info(f"Embedding model {config.embed_model} loaded")
logger.info(f"Embedding model {config.embed_model} loaded")
class ReRanker(FlagReranker):
def __init__(self, config, **kwargs):
assert config.reranker in RERANKER_LIST.keys(), f"Unsupported ReRanker: {config.reranker}, only support {RERANKER_LIST.keys()}"
model_name_or_path = config.model_local_paths.get(config.reranker, RERANKER_LIST[config.reranker])
logger.info(f"Loading ReRanker model {config.re_ranker} from {model_name_or_path}")
super().__init__(model_name_or_path, use_fp16=True, **kwargs)
logger.info(f"ReRanker model {config.re_ranker} loaded")

View File

@ -7,7 +7,7 @@ from copy import deepcopy
from tqdm import tqdm
def pdf2txt(pdf_path):
def pdf2txt(pdf_path, return_text=False):
output_dir = os.path.join('tmp', 'pdf2txt', os.path.basename(pdf_path).split('.')[0])
os.makedirs(output_dir, exist_ok=True)
@ -63,6 +63,9 @@ def pdf2txt(pdf_path):
with open(respath, 'w', encoding='utf-8') as f:
f.write(whole_text)
if return_text:
return whole_text
return respath
if __name__ == "__main__":

View File

@ -31,10 +31,11 @@ def chat_get():
def chat():
request_data = json.loads(request.data)
query = request_data['query']
meta = request_data.get('meta')
logger.debug(f"Web query: {query}")
history_manager = HistoryManager(request_data['history'])
new_query, refs = retriever(query, history_manager.messages)
new_query, refs = retriever(query, history_manager.messages, meta)
messages = history_manager.get_history_with_msg(new_query)
history_manager.add_user(query)
@ -62,4 +63,8 @@ def call():
return jsonify({
"response": response.content,
})
})
@common.route('/config', methods=['get'])
def get_config():
return jsonify(config)

View File

@ -28,6 +28,15 @@ def create_database():
database = dbm.create_database(database_name, description, db_type)
return jsonify(database)
# TODO: 删除数据库
@db.route('/', methods=['DELETE'])
def delete_database():
data = json.loads(request.data)
db_id = data.get('db_id')
logger.debug(f"Delete database {db_id}")
dbm.delete_database(db_id)
return jsonify({"message": "删除成功"})
@db.route('/add_by_file', methods=['POST'])
def create_document_by_file():
@ -53,9 +62,6 @@ def get_database_info():
return jsonify(database)
@db.route('/info', methods=['DELETE'])
def delete_database():
return jsonify({"message": "unimplemented"}), 501
@db.route('/document', methods=['DELETE'])
def delete_document():

1300
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,8 @@
},
"dependencies": {
"@ant-design/icons-vue": "^6.1.0",
"@antv/g6": "^5.0.9",
"@vueuse/core": "^10.11.0",
"ant-design-vue": "^4.2.3",
"axios": "^1.3.4",
"d3": "^7.8.3",

View File

@ -19,7 +19,24 @@
</div>
</div>
<div class="header__right">
<div class="nav-btn text" @click="myAlert('未开发')">张文杰</div>
<a-dropdown>
<a class="ant-dropdown-link nav-btn text" @click.prevent>
<component :is="state.selectedKB === null ? BookOutlined : BookFilled" />&nbsp;
{{ state.selectedKB === null ? '未选择' : state.databases[state.selectedKB]?.name }}
</a>
<template #overlay>
<a-menu>
<a-menu-item v-for="(db, index) in state.databases" :key="index">
<a href="javascript:;" @click="state.selectedKB=index">{{ db.name }}</a>
</a-menu-item>
<a-menu-item >
<a href="javascript:;" @click="state.selectedKB = null">不使用</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<div class="nav-btn text" @click="state.showPanel = !state.showPanel">张文杰</div>
<div v-if="state.showPanel" class="my-panal" ref="panel">暂时不知道干嘛的地方</div>
</div>
</div>
<div v-if="conv.messages.length == 0" class="chat-examples">
@ -43,7 +60,10 @@
:class="message.role"
>
<p v-if="message.role=='sent'" style="white-space: pre-line" class="message-text">{{ message.text }}</p>
<p v-else v-html="renderMarkdown(message.text)" class="message-md" ></p>
<p v-else
v-html="renderMarkdown(message.text)"
class="message-md"
@click="consoleMsg(message)"></p>
</div>
</div>
<div class="input-box">
@ -63,7 +83,15 @@
<script setup>
import { reactive, ref, onMounted, toRefs } from 'vue'
import { SendOutlined, MenuOutlined, FormOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import { onClickOutside } from '@vueuse/core'
import {
SendOutlined,
MenuOutlined,
FormOutlined,
LoadingOutlined,
BookOutlined,
BookFilled,
} from '@ant-design/icons-vue'
import { marked } from 'marked';
const props = defineProps({
@ -76,9 +104,10 @@ const emit = defineEmits(['renameTitle'])
const { conv, state } = toRefs(props)
const chatBox = ref(null)
const isStreaming = ref(false)
const panel = ref(null)
const examples = ref([
'写一个冒泡排序',
'介绍一下 MECT',
'肉碱是什么?',
'介绍一下江南大学',
'A大于BB小于CA和C哪个大',
'今天天气怎么样?'
@ -91,6 +120,8 @@ marked.setOptions({
// marked https://marked.js.org/
});
onClickOutside(panel, () => setTimeout(() => state.value.showPanel = false, 30))
const renameTitle = () => {
const prompt = '请用一个很短的句子关于下面的对话内容的主题起一个名字,不要带标点符号:'
const firstUserMessage = conv.value.messages[0].text
@ -101,13 +132,8 @@ const renameTitle = () => {
})
}
const myAlert = (message) => {
alert(message)
}
const renderMarkdown = (text) => {
return marked(text)
}
const myAlert = (message) => alert(message)
const renderMarkdown = (text) => marked(text)
const scrollToBottom = () => {
setTimeout(() => {
@ -115,6 +141,8 @@ const scrollToBottom = () => {
}, 10)
}
const consoleMsg = (message) => console.log(message)
const generateRandomHash = (length) => {
let chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let hash = '';
@ -192,6 +220,9 @@ const sendMessage = () => {
body: JSON.stringify({
query: user_input,
history: conv.value.history,
meta: {
db_name: state.value.databases[state.value.selectedKB]?.metaname
}
}),
headers: {
'Content-Type': 'application/json'
@ -274,10 +305,19 @@ onMounted(() => {
padding: 1rem;
}
.chat div.header .header__left {
display: flex;
align-items: center;
gap: 1rem;
.chat div.header {
user-select: none;
.header__left, .header__right {
display: flex;
align-items: center;
gap: 1rem;
}
}
.ant-dropdown-link {
color: var(--c-text-light-1);
cursor: pointer;
}
.nav-btn {
@ -302,6 +342,19 @@ onMounted(() => {
}
}
.my-panal {
position: absolute;
top: 100%;
right: 0;
margin-top: 5px;
background-color: white;
border: 1px solid #ccc;
box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.05);
border-radius: 12px;
padding: 12px;
z-index: 100;
}
div.chat-examples {
padding: 0 50px;

View File

@ -45,6 +45,12 @@ const router = createRouter({
path: ':database_id',
name: 'databaseInfo',
component: () => import('../views/DataBaseInfoView.vue'),
},
{
path: 'graph',
name: 'graph',
component: () => import('../views/GraphView.vue'),
meta: { keepAlive: true }
}
]
},

View File

@ -39,6 +39,9 @@ const convs = reactive(JSON.parse(localStorage.getItem('chat-convs')) || [
const state = reactive({
isSidebarOpen: true,
selectedKB: null,
showPanel: false,
databases: [],
})
const curConvId = ref(0)
@ -86,6 +89,18 @@ const delConv = (index) => {
}
}
const loadDatabases = () => {
fetch('/api/database/', {
method: "GET",
})
.then(response => response.json())
.then(data => {
console.log(data)
state.databases = data.databases
}
)
}
// Watch convs and save to localStorage
watch(
() => convs,
@ -97,6 +112,7 @@ watch(
// Load convs from localStorage on mount
onMounted(() => {
loadDatabases()
const savedSonvs = JSON.parse(localStorage.getItem('chat-convs'))
if (savedSonvs) {
for (let i = 0; i < savedSonvs.length; i++) {
@ -167,6 +183,7 @@ onMounted(() => {
padding: 16px;
cursor: pointer;
width: 100%;
user-select: none;
&__title {
white-space: nowrap; /* 禁止换行 */

View File

@ -1,15 +1,22 @@
<template>
<div style="display: flex;">
<div class="sider">
<a-button type="link" @click="backToDatabase"><LeftOutlined />返回知识库</a-button>
<div class="top">
<div class="icon"><ReadFilled /></div>
<div class="info">
<h3>{{ database.name }}</h3>
<p><span>{{ database.metaname }}</span> · <span>{{ database.metadata?.row_count }}</span></p>
<div class="sider-top">
<div class="header-actions">
<a-button type="text" @click="backToDatabase"><LeftOutlined /></a-button>
<a-button type="text" danger class="del-db" @click="deleteDatabse"><DeleteOutlined /></a-button>
</div>
<div class="top">
<div class="icon"><ReadFilled /></div>
<div class="info">
<h3>{{ database.name }}</h3>
<p><span>{{ database.metaname }}</span> · <span>{{ database.metadata?.row_count }}</span></p>
</div>
</div>
<p class="description">{{ database.description }}</p>
</div>
<div class="sider-bottom">
</div>
<p class="description">{{ database.description }}</p>
</div>
<div class="db-info-container">
<h2>向知识库中添加文件</h2>
@ -59,7 +66,11 @@
<ClockCircleFilled style="color: #FFCD43;"/>
</template>
<template v-else-if="column.key === 'action'">
<a-button class="del-btn" type="link" @click="deleteFile(text)" :disabled="state.lock">删除</a-button>
<a-button class="del-btn" type="link"
@click="deleteFile(text)"
:disabled="state.lock || record.status != 'done' "
>删除
</a-button>
</template>
<span v-else-if="column.key === 'created_at'">{{ formatRelativeTime(Math.round(text*1000)) }}</span>
<span v-else>{{ text }}</span>
@ -84,7 +95,7 @@
<script setup>
import { onMounted, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { message, Modal } from 'ant-design-vue';
import { useRoute, useRouter } from 'vue-router';
import {
ReadFilled,
@ -93,6 +104,7 @@ import {
HourglassFilled,
CloseCircleFilled,
ClockCircleFilled,
DeleteOutlined,
} from '@ant-design/icons-vue'
@ -109,6 +121,7 @@ const state = reactive({
refrashing: false,
lock: false,
drawer: false,
refreshInterval: null,
});
const handleFileUpload = (event) => {
@ -139,6 +152,41 @@ const handleRefresh = () => {
})
}
const deleteDatabse = () => {
Modal.confirm({
title: '删除数据库',
content: '确定要删除该数据库吗?',
okText: '确认',
cancelText: '取消',
onOk: () => {
state.lock = true
fetch('/api/database/', {
method: "DELETE",
body: JSON.stringify({
db_id: databaseId.value
}),
})
.then(response => response.json())
.then(data => {
console.log(data)
message.success(data.message)
router.push('/database')
})
.catch(error => {
console.error(error)
message.error(error.message)
})
.finally(() => {
state.lock = false
})
},
onCancel: () => {
console.log('Cancel');
},
});
}
const openFileDetail = (record) => {
state.lock = true
fetch(`/api/database/document?db_id=${databaseId.value}&file_id=${record.file_id}`, {
@ -230,7 +278,7 @@ const addDocumentByFile = () => {
state.loading = true
state.lock = true
const refreshInterval = setInterval(() => {
state.refreshInterval = setInterval(() => {
getDatabaseInfo();
}, 1000);
fetch('/api/database/add_by_file', {
@ -252,7 +300,7 @@ const addDocumentByFile = () => {
})
.finally(() => {
getDatabaseInfo()
clearInterval(refreshInterval)
clearInterval(state.refreshInterval)
state.loading = false
})
}
@ -293,7 +341,8 @@ const columns = [
watch(() => route.params.database_id, (newId) => {
databaseId.value = newId;
console.log(newId)
getDatabaseInfo();
clearInterval(state.refreshInterval)
getDatabaseInfo()
}
);
@ -308,13 +357,34 @@ onMounted(() => {
<style lang="less" scoped>
.sider {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 300px;
height: 100%;
padding: 20px;
padding: 0;
border-right: 1px solid #E0EAFF;
button {
margin-bottom: 20px;
.sider-top {
& > * {
padding: 0 20px;
}
.header-actions {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
padding-top: 10px;
padding-bottom: 10px;
background-color: #FAFAFA;
border-bottom: 1px solid #E0EAFF;
button {
height: auto;
font-size: 16px;
color: var(--c-text-light-1);
}
}
}
}
@ -394,9 +464,16 @@ onMounted(() => {
}
}
button.del-btn:hover {
button.del-btn {
cursor: pointer;
color: var(--error-color);
&:hover {
color: var(--error-color);
}
&:disabled {
cursor: not-allowed;
}
}
}
</style>

View File

@ -45,7 +45,7 @@
<h2>图数据库</h2>
<p>基于 neo4j 构建的图数据库</p>
<div :class="{'graphloading': graphloading}">
<div class="dbcard graphbase" >
<div class="dbcard graphbase" @click="navigateToGraph">
<div class="top">
<div class="icon"><AppstoreFilled /></div>
<div class="info">
@ -64,11 +64,12 @@
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router';
import { ref, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router';
import { message, Button } from 'ant-design-vue'
import { ReadFilled, PlusOutlined, AppstoreFilled } from '@ant-design/icons-vue'
const route = useRoute()
const router = useRouter()
const databases = ref([])
const graph = ref(null)
@ -80,7 +81,7 @@ const newDatabase = reactive({
loading: false,
})
const load_databases = () => {
const loadDatabases = () => {
loadGraph()
fetch('/api/database/', {
method: "GET",
@ -112,7 +113,7 @@ const createDatabase = () => {
.then(response => response.json())
.then(data => {
console.log(data)
load_databases()
loadDatabases()
newDatabase.open = false
newDatabase.name = ''
newDatabase.description = ''
@ -126,6 +127,10 @@ const navigateToDatabase = (databaseId) => {
router.push({ path: `/database/${databaseId}` });
};
const navigateToGraph = () => {
router.push({ path: `/database/graph` });
};
const loadGraph = () => {
graphloading.value = true
fetch('/api/database/graph', {
@ -144,8 +149,14 @@ const loadGraph = () => {
})
}
watch(() => route.path, (newPath, oldPath) => {
if (newPath === '/database') {
loadDatabases();
}
});
onMounted(() => {
load_databases()
loadDatabases()
})
</script>

View File

@ -0,0 +1,48 @@
<template>
<div class="graph-container">
<div class="main" id="container"></div>
</div>
</template>
<script setup>
import { Graph } from "@antv/g6";
import { onMounted } from 'vue';
const getCurWidth = () => document.getElementById("container").offsetWidth
const getCurHeight = () => document.getElementById("container").offsetHeight
onMounted(() => {
const graph = new Graph({
container: document.getElementById("container"),
width: getCurWidth(),
height: getCurHeight(),
data: {
nodes: [
{
id: "node-1",
style: { x: 50, y: 100 },
},
{
id: "node-2",
style: { x: 150, y: 100 },
},
],
edges: [{ id: "edge-1", source: "node-1", target: "node-2" }],
},
behaviors: ["drag-canvas", "zoom-canvas", "drag-element"],
});
graph.render();
});
</script>
<style scoped>
.graph-container {}
#container {
width: 100%;
height: 100%;
}
</style>

View File

@ -20,5 +20,8 @@ export default defineConfig({
rewrite: (path) => path.replace(/^\/api/, '')
}
},
watch: {
ignored: ['**/node_modules/**', '**/dist/**'],
},
}
})