Merge pull request #9 from xerrors/dev

Dev
This commit is contained in:
Wenjie Zhang 2024-09-06 12:54:54 +08:00 committed by GitHub
commit 10a2f036c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 712 additions and 385 deletions

8
.gitignore vendored
View File

@ -25,13 +25,19 @@ cache
### IDE ### IDE
.vscode .vscode
.idea
*.nogit.* *.nogit.*
*.pdf *.pdf
*.yaml
src/data src/data
neo4j* neo4j*
*/package-lock.json */package-lock.json
web/package-lock.json web/package-lock.json
saves saves
notebooks notebooks
*.yaml local_neo4j/data
local_neo4j/logs
local_neo4j/import
local_neo4j/plugins
local_neo4j/conf

View File

@ -3,22 +3,42 @@
<img src="web/public/home.png" style="border-radius: 16px; margin: 0 auto; max-height: 400px; display: block;"/> <img src="web/public/home.png" style="border-radius: 16px; margin: 0 auto; max-height: 400px; display: block;"/>
### 准备 ## 准备
1. 提供 API 服务商的 API_KEY并放置在 `src/.env` 文件中,参考 `src/.env.template`。默认使用的是智谱AI。 1. 提供 API 服务商的 API_KEY并放置在 `src/.env` 文件中,参考 `src/.env.template`。默认使用的是智谱AI。
2. 配置 python 环境 `pip install -r src/requirements.txt` 2. 配置 python 环境 `pip install -r requirements.txt`
**如果不启用知识库,可以仅安装下面的依赖**
### 启动命令行模式 ```
FlagEmbedding==1.2.10
```bash Flask==3.0.3
python -m src.cli Flask_Cors==4.0.1
openai==1.35.10
python-dotenv==1.0.1
PyYAML==6.0.1
zhipuai
``` ```
### 启动网页模式 ### 【可选】配置图数据库 neo4j
使用 docker 部署 neo4j 服务,配置文件见 [local_neo4j/docker-compose.yml](local_neo4j/docker-compose.yml).
默认账号密码见最后一行,可以使用 `http://localhost:7474/` 在浏览器可视化访问。
```bash ```bash
python -m src.api cd local_neo4j
docker compose up -d
```
可以使用 `python test_neo4j.py` 来测试是否正常启动。使用 `docker compose down` 可停止服务。
如果想要管理 neo4j也可以使用 `docker ps` 查看容器 id然后使用 `docker exec -it <CONTAINER_ID> /bin/bash` 进入容器。
如果想要删除数据库中的文件,可以进入容器并停止 neo4j 后,执行 `rm -rf /data/databases`
## 启动
```bash
python -m src.api
cd web cd web
npm install npm install

View File

@ -0,0 +1,17 @@
version: '3.9'
services:
neo4j:
image: neo4j:latest
volumes:
- ./conf:/var/lib/neo4j/conf
- ./import:/var/lib/neo4j/import
- ./plugins:/plugins
- ./data:/data
- ./logs:/var/lib/neo4j/logs
restart: always
ports:
- 7474:7474
- 7687:7687
environment:
- NEO4J_AUTH=neo4j/0123456789

34
local_neo4j/test_neo4j.py Normal file
View File

@ -0,0 +1,34 @@
from neo4j import GraphDatabase
from neo4j.exceptions import ServiceUnavailable, AuthError
def check_neo4j_status(uri="bolt://localhost:7687", username="neo4j", password="0123456789"):
"""
检查 Neo4j 数据库是否可以连接并正常工作
参数:
uri (str): Neo4j URI默认为 "bolt://localhost:7687"
username (str): 数据库用户名默认为 "neo4j"
password (str): 数据库密码默认为 "0123456789"
返回:
str: "OK" 表示连接成功"UNAVAILABLE" 表示服务不可用"AUTH_FAILED" 表示认证失败
"""
try:
driver = GraphDatabase.driver(uri, auth=(username, password))
with driver.session() as session:
# 简单的查询来测试连接
result = session.run("RETURN 1")
if result.single()[0] == 1:
return "OK"
except ServiceUnavailable:
return "UNAVAILABLE"
except AuthError:
return "AUTH_FAILED"
finally:
# 确保关闭驱动
driver.close()
# 测试函数
status = check_neo4j_status()
print(f"Neo4j status: {status}")

18
requirements.txt Normal file
View File

@ -0,0 +1,18 @@
dashscope==1.20.5
FlagEmbedding==1.2.11
Flask==3.0.3
Flask_Cors==4.0.1
llama_index==0.11.1
neo4j==5.23.1
openai==1.42.0
paddleocr==2.8.1
pymilvus==2.4.5
python-dotenv==1.0.1
PyYAML==6.0.2
qianfan==0.4.6
torch==2.4.0
tqdm==4.66.5
zhipuai==2.1.4.20230814
PyMuPDF
llama-index-readers-file
peft

View File

@ -38,12 +38,12 @@ class Config(SimpleConfig):
### >>> 默认配置 ### >>> 默认配置
# 可以在 config/base.yaml 中覆盖 # 可以在 config/base.yaml 中覆盖
self.add_item("mode", default="cli", des="运行模式", choices=["cli", "api"])
self.add_item("stream", default=True, des="是否开启流式输出") self.add_item("stream", default=True, des="是否开启流式输出")
self.add_item("save_dir", default="saves", des="保存目录") self.add_item("save_dir", default="saves", des="保存目录")
# 功能选项 # 功能选项
self.add_item("enable_reranker", default=False, des="是否开启重排序") self.add_item("enable_reranker", default=False, des="是否开启重排序")
self.add_item("enable_knowledge_base", default=False, des="是否开启知识库") self.add_item("enable_knowledge_base", default=False, des="是否开启知识库")
self.add_item("enable_knowledge_graph", default=False, des="是否开启知识图谱")
self.add_item("enable_search_engine", default=False, des="是否开启搜索引擎") self.add_item("enable_search_engine", default=False, des="是否开启搜索引擎")
# 模型配置 # 模型配置
@ -70,6 +70,13 @@ class Config(SimpleConfig):
"choices": choices "choices": choices
} }
def __dict__(self):
blocklist = [
"_config_items",
"model_names",
]
return {k: v for k, v in self.items() if k not in blocklist}
def handle_self(self): def handle_self(self):
### handle local model ### handle local model
model_root_dir = os.getenv("MODEL_ROOT_DIR", "pretrained_models") model_root_dir = os.getenv("MODEL_ROOT_DIR", "pretrained_models")
@ -98,7 +105,6 @@ class Config(SimpleConfig):
content = f.read() content = f.read()
if content: if content:
local_config = json.loads(content) local_config = json.loads(content)
local_config.pop("_config_items")
self.update(local_config) self.update(local_config)
else: else:
print(f"{self.filename} is empty.") print(f"{self.filename} is empty.")
@ -108,7 +114,6 @@ class Config(SimpleConfig):
content = f.read() content = f.read()
if content: if content:
local_config = yaml.safe_load(content) local_config = yaml.safe_load(content)
local_config.pop("_config_items")
self.update(local_config) self.update(local_config)
else: else:
print(f"{self.filename} is empty.") print(f"{self.filename} is empty.")

View File

@ -8,46 +8,6 @@ from src.models.embedding import get_embedding_model
logger = setup_logger("DataBaseManager") logger = setup_logger("DataBaseManager")
class DataBaseLite:
def __init__(self, name, description, db_type, dimension=None, **kwargs) -> None:
self.name = name
self.description = description
self.db_type = db_type
self.dimension = dimension
self.db_id = kwargs.get("db_id", hashstr(name))
self.metaname = kwargs.get("metaname", f"{db_type[:1]}{hashstr(name)}")
self.metadata = kwargs.get("metaname", {})
self.files = kwargs.get("files", [])
self.embed_model = kwargs.get("embed_model", None)
def id2file(self, file_id):
for f in self.files:
if f["file_id"] == file_id:
return f
return None
def update(self, metadata):
self.metadata = metadata
def to_dict(self):
return {
"name": self.name,
"description": self.description,
"db_type": self.db_type,
"db_id": self.db_id,
"embed_model": self.embed_model,
"metaname": self.metaname,
"metadata": self.metadata,
"files": self.files,
"dimension": self.dimension
}
def to_json(self):
return json.dumps(self.to_dict(), ensure_ascii=False)
def __str__(self):
return self.to_json()
class DataBaseManager: class DataBaseManager:
def __init__(self, config=None) -> None: def __init__(self, config=None) -> None:
@ -111,13 +71,16 @@ class DataBaseManager:
return {"databases": [db.to_dict() for db in self.data["databases"]]} return {"databases": [db.to_dict() for db in self.data["databases"]]}
def get_graph(self): def get_graph(self):
if self.config.enable_graph_base: if self.config.enable_knowledge_graph:
self.data["graph"].update(self.graph_base.get_database_info("neo4j")) self.data["graph"].update(self.graph_base.get_database_info("neo4j"))
return {"graph": self.data["graph"]} return {"graph": self.data["graph"]}
else: else:
return {"message": "Graph base not enabled", "graph": {}} return {"message": "Graph base not enabled", "graph": {}}
def create_database(self, database_name, description, db_type, dimension): def create_database(self, database_name, description, db_type, dimension):
from src.config import EMBED_MODEL_INFO
dimension = dimension or EMBED_MODEL_INFO[self.config.embed_model]["dimension"]
new_database = DataBaseLite(database_name, new_database = DataBaseLite(database_name,
description, description,
db_type, db_type,
@ -134,7 +97,7 @@ class DataBaseManager:
if db.embed_model != self.config.embed_model: if db.embed_model != self.config.embed_model:
logger.error(f"Embed model not match, {db.embed_model} != {self.config.embed_model}") logger.error(f"Embed model not match, {db.embed_model} != {self.config.embed_model}")
return {"message": "Embed model not match", "status": "failed"} return {"message": f"Embed model not match, cur: {self.config.embed_model}", "status": "failed"}
new_files = [] new_files = []
for file in files: for file in files:
@ -208,7 +171,6 @@ class DataBaseManager:
logger.error(f"File format not supported, only support {support_format}") logger.error(f"File format not supported, only support {support_format}")
raise Exception(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): def delete_file(self, db_id, file_id):
db = self.get_kb_by_id(db_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] file_idx_to_delete = [idx for idx, f in enumerate(db.files) if f["file_id"] == file_id][0]
@ -252,4 +214,45 @@ class DataBaseManager:
for db in self.data["databases"]: for db in self.data["databases"]:
if db.db_id == db_id: if db.db_id == db_id:
return db return db
return None return None
class DataBaseLite:
def __init__(self, name, description, db_type, dimension=None, **kwargs) -> None:
self.name = name
self.description = description
self.db_type = db_type
self.dimension = dimension
self.db_id = kwargs.get("db_id", hashstr(name))
self.metaname = kwargs.get("metaname", f"{db_type[:1]}{hashstr(name)}")
self.metadata = kwargs.get("metaname", {})
self.files = kwargs.get("files", [])
self.embed_model = kwargs.get("embed_model", None)
def id2file(self, file_id):
for f in self.files:
if f["file_id"] == file_id:
return f
return None
def update(self, metadata):
self.metadata = metadata
def to_dict(self):
return {
"name": self.name,
"description": self.description,
"db_type": self.db_type,
"db_id": self.db_id,
"embed_model": self.embed_model,
"metaname": self.metaname,
"metadata": self.metadata,
"files": self.files,
"dimension": self.dimension
}
def to_json(self):
return json.dumps(self.to_dict(), ensure_ascii=False)
def __str__(self):
return self.to_json()

View File

@ -9,11 +9,12 @@ import warnings
from src.plugins import pdf2txt from src.plugins import pdf2txt
from src.plugins.oneke import OneKE from src.plugins.oneke import OneKE
from src.utils import setup_logger
logger = setup_logger("server-graphbase")
warnings.filterwarnings("ignore", category=UserWarning) warnings.filterwarnings("ignore", category=UserWarning)
UIE_MODEL = None UIE_MODEL = None
class GraphDatabase: class GraphDatabase:
@ -36,6 +37,16 @@ class GraphDatabase:
"""关闭数据库连接""" """关闭数据库连接"""
self.driver.close() self.driver.close()
def get_sample_nodes(self, kgdb_name='neo4j', num=50):
"""获取指定数据库的前 num 个节点信息"""
self.use_database(kgdb_name)
def query(tx, num):
result = tx.run("MATCH (n)-[r]->(m) RETURN n, r, m LIMIT $num", num=int(num))
return result.values()
with self.driver.session() as session:
return session.execute_read(query, num)
def create_graph_database(self, kgdb_name): def create_graph_database(self, kgdb_name):
"""创建新的数据库,如果已存在则返回已有数据库的名称""" """创建新的数据库,如果已存在则返回已有数据库的名称"""
with self.driver.session() as session: with self.driver.session() as session:
@ -116,21 +127,25 @@ class GraphDatabase:
MERGE (t:Entity {name: $t}) MERGE (t:Entity {name: $t})
MERGE (h)-[r:RELATION {type: $r}]->(t) MERGE (h)-[r:RELATION {type: $r}]->(t)
""", h=entry['h'], t=entry['t'], r=entry['r']) """, h=entry['h'], t=entry['t'], r=entry['r'])
def _create_vector_index(tx): def _create_vector_index(tx, dim):
index_name = "entity-embeddings" index_name = "entityEmbeddings"
if not _index_exists(tx, index_name): if not _index_exists(tx, index_name):
tx.run(f""" tx.run(f"""
CREATE VECTOR INDEX {index_name} CREATE VECTOR INDEX {index_name}
FOR (n: Entity) ON (n.embedding) FOR (n: Entity) ON (n.embedding)
OPTIONS {{indexConfig: {{ OPTIONS {{indexConfig: {{
`vector.dimensions`: 1024, `vector.dimensions`: {dim},
`vector.similarity_function`: 'cosine' `vector.similarity_function`: 'cosine'
}} }}; }} }};
""") """)
from src.config import EMBED_MODEL_INFO
embed_info = EMBED_MODEL_INFO[self.config.embed_model]
with self.driver.session() as session: with self.driver.session() as session:
session.execute_write(_create_graph, triples) session.execute_write(_create_graph, triples)
session.execute_write(_create_vector_index) session.execute_write(_create_vector_index, embed_info.dimension)
for entry in triples: for i, entry in enumerate(triples):
logger.info(f"Adding entity {i+1}/{len(triples)}")
embedding_h = self.get_embedding(entry['h']) embedding_h = self.get_embedding(entry['h'])
session.execute_write(self.set_embedding, entry['h'], embedding_h) session.execute_write(self.set_embedding, entry['h'], embedding_h)
@ -148,37 +163,39 @@ class GraphDatabase:
triples = list(read_triples(file_path)) triples = list(read_triples(file_path))
def batch_create(tx, triples): self.txt_add_vector_entity(triples, kgdb_name)
query = """
UNWIND $triples AS triple
MERGE (a:Entity {name: triple.h})
MERGE (b:Entity {name: triple.t})
MERGE (a)-[r:RELATION {type: triple.r}]->(b)
"""
tx.run(query, triples=triples)
def batch_add_embeddings(tx, embeddings): # def batch_create(tx, triples):
query = """ # query = """
UNWIND $embeddings AS embedding # UNWIND $triples AS triple
MATCH (e:Entity {name: embedding.name}) # MERGE (a:Entity {name: triple.h})
SET e.embedding = embedding.vector # MERGE (b:Entity {name: triple.t})
""" # MERGE (a)-[r:RELATION {type: triple.r}]->(b)
tx.run(query, embeddings=embeddings) # """
# tx.run(query, triples=triples)
with self.driver.session() as session: #
session.execute_write(batch_create, triples) # def batch_add_embeddings(tx, embeddings):
# query = """
# 获取embedding并批量添加 # UNWIND $embeddings AS embedding
embeddings = [] # MATCH (e:Entity {name: embedding.name})
for triple in triples: # SET e.embedding = embedding.vector
h = triple['h'] # """
t = triple['t'] # tx.run(query, embeddings=embeddings)
embedding_h = self.get_embedding(h) #
embedding_t = self.get_embedding(t) # with self.driver.session() as session:
embeddings.append({"name": h, "vector": embedding_h}) # session.execute_write(batch_create, triples)
embeddings.append({"name": t, "vector": embedding_t}) #
# # 获取embedding并批量添加
session.execute_write(batch_add_embeddings, embeddings) # embeddings = []
# for triple in triples:
# h = triple['h']
# t = triple['t']
# embedding_h = self.get_embedding(h)
# embedding_t = self.get_embedding(t)
# embeddings.append({"name": h, "vector": embedding_h})
# embeddings.append({"name": t, "vector": embedding_t})
#
# session.execute_write(batch_add_embeddings, embeddings)
self.status = "open" self.status = "open"
return kgdb_name return kgdb_name
@ -260,13 +277,21 @@ class GraphDatabase:
with self.driver.session() as session: with self.driver.session() as session:
return session.execute_read(query, keyword, hops) return session.execute_read(query, keyword, hops)
def query_node(self, entity_name, args):
# TODO 添加判断节点数量为 0 停止检索
if args.get("exact_match"):
raise NotImplemented("not implement for `exact_match`")
else:
return self.query_by_vector(entity_name, kgdb_name=args.get("kgdb_name"), hops=args.get("hops"))
def query_by_vector_tep(self, keyword, kgdb_name='neo4j'): def query_by_vector_tep(self, keyword, kgdb_name='neo4j'):
"""向量查询""" """向量查询"""
self.use_database(kgdb_name) self.use_database(kgdb_name)
def query(tx, text): def query(tx, text):
embedding = self.get_embedding(text) embedding = self.get_embedding(text)
result = tx.run(""" result = tx.run("""
CALL db.index.vector.queryNodes('entity-embeddings', 10, $embedding) CALL db.index.vector.queryNodes('entityEmbeddings', 10, $embedding)
YIELD node AS similarEntity, score YIELD node AS similarEntity, score
RETURN similarEntity.name AS name, score RETURN similarEntity.name AS name, score
""", embedding=embedding) """, embedding=embedding)
@ -277,7 +302,7 @@ class GraphDatabase:
with self.driver.session() as session: with self.driver.session() as session:
return session.execute_read(query, keyword) return session.execute_read(query, keyword)
def query_by_vector(self, entity_name, threshold=0.9,kgdb_name='neo4j', hops=2, num_of_res=2): def query_by_vector(self, entity_name, threshold=0.9, kgdb_name='neo4j', hops=2, num_of_res=2):
self.use_database(kgdb_name) self.use_database(kgdb_name)
result = self.query_by_vector_tep(entity_name) result = self.query_by_vector_tep(entity_name)
querys = [] querys = []

View File

@ -83,7 +83,7 @@ class Retriever:
r["file"] = kb.id2file(r["entity"]["file_id"]) r["file"] = kb.id2file(r["entity"]["file_id"])
if self.config.enable_reranker: if self.config.enable_reranker:
RERANK_THRESHOLD = 0.1 RERANK_THRESHOLD = 0.001
for r in kb_res: for r in kb_res:
r["rerank_score"] = self.reranker.compute_score([query, r["entity"]["text"]], normalize=True) r["rerank_score"] = self.reranker.compute_score([query, r["entity"]["text"]], normalize=True)
kb_res.sort(key=lambda x: x["rerank_score"], reverse=True) kb_res.sort(key=lambda x: x["rerank_score"], reverse=True)
@ -124,7 +124,46 @@ class Retriever:
return entities return entities
def foramt_general_results(self, results):
logger.debug(f"Formatting general results: {results}")
formatted_results = {"nodes": [], "edges": []}
for item in results:
relationship = item[1]
rel_id = relationship.element_id
nodes = relationship.nodes
if len(nodes) != 2:
continue
source, target = nodes
source_id = source.element_id
target_id = target.element_id
source_name = source._properties.get('name', 'unknown')
target_name = target._properties.get('name', 'unknown')
if source_id not in formatted_results["nodes"]:
formatted_results["nodes"].append({"id": source_id, "name": source_name})
if target_id not in formatted_results["nodes"]:
formatted_results["nodes"].append({"id": target_id, "name": target_name})
relationship_type = relationship._properties.get('type', 'unknown')
if relationship_type == 'unknown':
relationship_type = relationship.type
formatted_results["edges"].append({
"id": rel_id,
"type": relationship_type,
"source_id": source_id,
"target_id": target_id,
"source_name": source_name,
"target_name": target_name
})
return formatted_results
def format_query_results(self, results): def format_query_results(self, results):
logger.debug(f"Formatting query results: {results}")
formatted_results = {"nodes": [], "edges": []} formatted_results = {"nodes": [], "edges": []}
node_dict = {} node_dict = {}

View File

@ -45,10 +45,11 @@ class ZhipuEmbedding:
self.query_instruction_for_retrieval = "为这个句子生成表示以用于检索相关文章:" self.query_instruction_for_retrieval = "为这个句子生成表示以用于检索相关文章:"
def predict(self, message): def predict(self, message):
data = [] data = []
for i in range(0, len(message), 10): for i in range(0, len(message), 10):
if len(message) > 10:
logger.info(f"Encoding {i} to {i+10} with {len(message)} messages")
group_msg = message[i:i+10] group_msg = message[i:i+10]
response = self.client.embeddings.create( response = self.client.embeddings.create(
model=self.model_info.default_path, model=self.model_info.default_path,

View File

@ -1,6 +0,0 @@
FlagEmbedding==1.2.10
Flask==3.0.3
Flask_Cors==4.0.1
openai==1.35.10
python-dotenv==1.0.1
PyYAML==6.0.1

View File

@ -123,9 +123,20 @@ def get_graph_node():
return jsonify({'message': 'entity_name and kgdb_name are required'}), 400 return jsonify({'message': 'entity_name and kgdb_name are required'}), 400
logger.debug(f"Get graph node {entity_name} in {kgdb_name} with {hops} hops") logger.debug(f"Get graph node {entity_name} in {kgdb_name} with {hops} hops")
result = startup.dbm.graph_base.query_by_vector(entity_name, kgdb_name=kgdb_name, hops=hops) result = startup.dbm.graph_base.query_node(entity_name, request.args)
return jsonify({'result': startup.retriever.format_query_results(result), 'message': 'success'}), 200 return jsonify({'result': startup.retriever.format_query_results(result), 'message': 'success'}), 200
@db.route('/graph/nodes', methods=['GET'])
def get_graph_nodes():
kgdb_name = request.args.get('kgdb_name')
num = request.args.get('num')
if not kgdb_name:
return jsonify({'message': 'kgdb_name is required'}), 400
logger.debug(f"Get graph nodes in {kgdb_name} with {num} nodes")
result = startup.dbm.graph_base.get_sample_nodes(kgdb_name, num)
return jsonify({'result': startup.retriever.foramt_general_results(result), 'message': 'success'}), 200
@db.route('/graph/add', methods=['POST']) @db.route('/graph/add', methods=['POST'])
def add_graph_entity(): def add_graph_entity():
data = json.loads(request.data) data = json.loads(request.data)

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.svg"> <link rel="icon" href="/favicon.svg">

View File

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

View File

@ -11,6 +11,7 @@
--main-100: #ABE0F7; --main-100: #ABE0F7;
--main-50: #CDF5FF; --main-50: #CDF5FF;
--main-25: #E6FAFF; --main-25: #E6FAFF;
--main-10: #F5FDFF;
--c-white: #ffffff; --c-white: #ffffff;
--c-white-soft: #f8f8f8; --c-white-soft: #f8f8f8;

View File

@ -2,4 +2,16 @@
:root { :root {
--header-height: 60px; --header-height: 60px;
}
/* layout */
.layout-container {
width: 100%;
padding: 0px 30px;
background-color: #FCFEFF;
h2 {
margin: 20px 0 10px 0;
}
} }

View File

@ -14,7 +14,7 @@
class="newchat nav-btn" class="newchat nav-btn"
@click="$emit('newconv')" @click="$emit('newconv')"
> >
<PlusCircleOutlined /> <span class="text">新对话 {{ configStore.config?.model_name }}</span> <PlusCircleOutlined /> <span class="text">新对话{{ configStore.config?.model_name }}</span>
</div> </div>
</div> </div>
<div class="header__right"> <div class="header__right">
@ -107,11 +107,12 @@
:class="message.role" :class="message.role"
> >
<p v-if="message.role=='sent'" style="white-space: pre-line" class="message-text">{{ message.text }}</p> <p v-if="message.role=='sent'" style="white-space: pre-line" class="message-text">{{ message.text }}</p>
<div v-else-if="message.text.length == 0" class="loading-dots"> <div v-else-if="message.text.length == 0 && message.status=='querying'" class="loading-dots">
<div></div> <div></div>
<div></div> <div></div>
<div></div> <div></div>
</div> </div>
<div v-else-if="message.text.length == 0 || message.status == 'error'" class="err-msg">请求错误请重试</div>
<p v-else <p v-else
v-html="renderMarkdown(message)" v-html="renderMarkdown(message)"
class="message-md" class="message-md"
@ -182,7 +183,8 @@ const examples = ref([
'肉碱的分子量是多少?直接回答', '肉碱的分子量是多少?直接回答',
'简述大蒜的功效是什么?', '简述大蒜的功效是什么?',
'A大于BB小于CA和C哪个大', 'A大于BB小于CA和C哪个大',
'今天天气怎么样?' '今天天气怎么样?',
'吃饭吃出苍蝇可以索赔吗?',
]) ])
const opts = reactive({ const opts = reactive({
@ -329,6 +331,7 @@ const updateStatus = (id, status) => {
return acc; return acc;
}, {}) }, {})
} }
scrollToBottom()
} }
const simpleCall = (message) => { const simpleCall = (message) => {
@ -403,6 +406,11 @@ const sendMessage = () => {
} }
return readChunk() return readChunk()
}) })
.catch((error) => {
console.error(error)
updateStatus(cur_res_id, "error")
isStreaming.value = false
})
} else { } else {
console.log('请输入消息') console.log('请输入消息')
} }
@ -573,7 +581,7 @@ watch(
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
flex-grow: 1; flex-grow: 1;
padding: 1rem; padding: 1rem 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -591,6 +599,15 @@ watch(
color: black; color: black;
/* box-shadow: 0px 0.3px 0.9px rgba(0, 0, 0, 0.12), 0px 1.6px 3.6px rgba(0, 0, 0, 0.16); */ /* box-shadow: 0px 0.3px 0.9px rgba(0, 0, 0, 0.12), 0px 1.6px 3.6px rgba(0, 0, 0, 0.16); */
/* animation: slideInUp 0.1s ease-in; */ /* animation: slideInUp 0.1s ease-in; */
.err-msg {
color: red;
border: 1px solid red;
padding: 0.2rem 1rem;
border-radius: 8px;
text-align: center;
background: #FFEBEE;
}
} }
.message-box.sent { .message-box.sent {
@ -623,8 +640,6 @@ watch(
word-wrap: break-word; word-wrap: break-word;
margin-bottom: 0; margin-bottom: 0;
} }
} }
@ -773,7 +788,7 @@ button:disabled {
@keyframes loading {0%,80%,100%{transform:scale(0.5);}40%{transform:scale(1);}} @keyframes loading {0%,80%,100%{transform:scale(0.5);}40%{transform:scale(1);}}
.slide-out-left{-webkit-animation:slide-out-left .2s cubic-bezier(.55,.085,.68,.53) both;animation:slide-out-left .5s cubic-bezier(.55,.085,.68,.53) both} .slide-out-left{-webkit-animation:slide-out-left .2s cubic-bezier(.55,.085,.68,.53) both;animation:slide-out-left .5s cubic-bezier(.55,.085,.68,.53) both}
.swing-in-top-fwd {-webkit-animation: swing-in-top-fwd 0.2s cubic-bezier(0.175, 0.885, 0.320, 1.275) both;animation: swing-in-top-fwd 0.5s cubic-bezier(0.175, 0.885, 0.320, 1.275) both;} .swing-in-top-fwd{-webkit-animation:swing-in-top-fwd .2s ease-out both;animation:swing-in-top-fwd .2s ease-out both}
@-webkit-keyframes swing-in-top-fwd{0%{-webkit-transform:rotateX(-100deg);transform:rotateX(-100deg);-webkit-transform-origin:top;transform-origin:top;opacity:0}100%{-webkit-transform:rotateX(0deg);transform:rotateX(0deg);-webkit-transform-origin:top;transform-origin:top;opacity:1}}@keyframes swing-in-top-fwd{0%{-webkit-transform:rotateX(-100deg);transform:rotateX(-100deg);-webkit-transform-origin:top;transform-origin:top;opacity:0}100%{-webkit-transform:rotateX(0deg);transform:rotateX(0deg);-webkit-transform-origin:top;transform-origin:top;opacity:1}} @-webkit-keyframes swing-in-top-fwd{0%{-webkit-transform:rotateX(-100deg);transform:rotateX(-100deg);-webkit-transform-origin:top;transform-origin:top;opacity:0}100%{-webkit-transform:rotateX(0deg);transform:rotateX(0deg);-webkit-transform-origin:top;transform-origin:top;opacity:1}}@keyframes swing-in-top-fwd{0%{-webkit-transform:rotateX(-100deg);transform:rotateX(-100deg);-webkit-transform-origin:top;transform-origin:top;opacity:0}100%{-webkit-transform:rotateX(0deg);transform:rotateX(0deg);-webkit-transform-origin:top;transform-origin:top;opacity:1}}
@-webkit-keyframes slide-out-left{0%{-webkit-transform:translateX(0);transform:translateX(0);opacity:1}100%{-webkit-transform:translateX(-1000px);transform:translateX(-1000px);opacity:0}}@keyframes slide-out-left{0%{-webkit-transform:translateX(0);transform:translateX(0);opacity:1}100%{-webkit-transform:translateX(-1000px);transform:translateX(-1000px);opacity:0}} @-webkit-keyframes slide-out-left{0%{-webkit-transform:translateX(0);transform:translateX(0);opacity:1}100%{-webkit-transform:translateX(-1000px);transform:translateX(-1000px);opacity:0}}@keyframes slide-out-left{0%{-webkit-transform:translateX(0);transform:translateX(0);opacity:1}100%{-webkit-transform:translateX(-1000px);transform:translateX(-1000px);opacity:0}}

View File

@ -1,8 +1,8 @@
<!-- RefsComponent.vue --> <!-- RefsComponent.vue -->
<template> <template>
<div class="refs" v-if="showRefs"> <div class="refs" v-if="showRefs">
<span class="item"><GlobalOutlined /> {{ message.model_name }}</span>
<div class="tags"> <div class="tags">
<span class="item"><GlobalOutlined /> {{ message.model_name }}</span>
<span class="filetag item" <span class="filetag item"
v-for="(results, filename) in message.groupedResults" v-for="(results, filename) in message.groupedResults"
:key="filename" :key="filename"
@ -28,13 +28,13 @@
<p class="result-distance"> <p class="result-distance">
<strong>相似度距离:</strong> <strong>相似度距离:</strong>
<div class="scorebar"> <div class="scorebar">
<a-progress :percent="(res.distance * 100).toFixed(2)" stroke-color="#1677FF" :size="[200, 10]"/> <a-progress :percent="parseFloat((res.distance * 100).toFixed(2))" stroke-color="#1677FF" :size="[200, 10]"/>
</div> </div>
</p> </p>
<p class="result-rerank-score"> <p class="result-rerank-score" v-if="res.rerank_score">
<strong>重排序分数:</strong> <strong>重排序分数:</strong>
<div class="scorebar"> <div class="scorebar">
<a-progress :percent="(res.rerank_score * 100).toFixed(2)" stroke-color="#1677FF" :size="[200, 10]"/> <a-progress :percent="parseFloat((res.rerank_score * 100).toFixed(2))" stroke-color="#1677FF" :size="[200, 10]"/>
</div> </div>
</p> </p>
<a-divider /> <a-divider />
@ -72,9 +72,8 @@ const showRefs = computed(() => message.value.role=='received' && message.value.
gap: 10px; gap: 10px;
.item { .item {
background: var(--main-25); background: var(--main-10);
color: var(--main-800); color: var(--main-600);
border: 1px solid var(--main-100);
padding: 2px 8px; padding: 2px 8px;
border-radius: 8px; border-radius: 8px;
font-size: 14px; font-size: 14px;
@ -82,6 +81,7 @@ const showRefs = computed(() => message.value.role=='received' && message.value.
.tags { .tags {
display: flex; display: flex;
flex-wrap: wrap;
gap: 10px; gap: 10px;
.filetag { .filetag {
@ -91,7 +91,7 @@ const showRefs = computed(() => message.value.role=='received' && message.value.
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: var(--main-100); background: var(--main-25);
} }
} }
} }

View File

@ -11,6 +11,9 @@ import {
GithubOutlined, GithubOutlined,
DatabaseOutlined, DatabaseOutlined,
DatabaseFilled, DatabaseFilled,
GoldOutlined,
GoldFilled,
BugOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { themeConfig } from '@/assets/theme' import { themeConfig } from '@/assets/theme'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
@ -52,7 +55,7 @@ console.log(route)
<template> <template>
<div class="app-layout"> <div class="app-layout">
<div class="debug-panel"> <div class="debug-panel">
<div class="shown-btn" @click="showDebug=!showDebug">Debug</div> <div class="shown-btn" @click="showDebug=!showDebug"><BugOutlined /></div>
<a-drawer <a-drawer
v-model:open="showDebug" v-model:open="showDebug"
title="调试面板" title="调试面板"
@ -74,6 +77,9 @@ console.log(route)
<RouterLink to="/database" class="nav-item" active-class="active"> <RouterLink to="/database" class="nav-item" active-class="active">
<component class="icon" :is="route.path.startsWith('/database') ? DatabaseFilled : DatabaseOutlined" /> <component class="icon" :is="route.path.startsWith('/database') ? DatabaseFilled : DatabaseOutlined" />
</RouterLink> </RouterLink>
<RouterLink to="/graph" class="nav-item" active-class="active">
<component class="icon" :is="route.path.startsWith('/graph') ? GoldFilled: GoldOutlined" />
</RouterLink>
</div> </div>
<div class="fill" style="flex-grow: 1;"></div> <div class="fill" style="flex-grow: 1;"></div>
<div class="github nav-item"> <div class="github nav-item">
@ -118,11 +124,13 @@ console.log(route)
position: absolute; position: absolute;
z-index: 100; z-index: 100;
right: 0; right: 0;
top: 50px; bottom: 50px;
border-radius: 16px 0 0 16px; border-radius: 20px 0 0 20px;
background-color: var(--main-light-3); background-color: var(--main-light-4);
padding: 8px 8px 8px 16px; padding: 6px;
box-shadow: 0 0 20px 10px rgba(0, 0, 0, 0.1); padding-left: 12px;
box-shadow: 0 0 10px 5px rgba(0, 0, 0, 0.05);
border: 1px solid var(--c-black-soft);
transition: right 0.3s ease-in-out; transition: right 0.3s ease-in-out;
cursor: pointer; cursor: pointer;
} }
@ -181,11 +189,11 @@ div.header, #app-router-view {
&.active { &.active {
font-weight: bold; font-weight: bold;
color: var(--main-600); color: var(--main-600);
background-color: #E6E8E9; background-color: rgba( 0, 93, 125, 0.1);
} }
&:hover { &:hover {
background-color: #E6E8E9; background-color: rgba( 0, 93, 125, 0.1);
cursor: pointer; cursor: pointer;
} }
} }

View File

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

View File

@ -3,7 +3,7 @@
<div class="conversations" :class="['conversations', { 'is-open': state.isSidebarOpen }]"> <div class="conversations" :class="['conversations', { 'is-open': state.isSidebarOpen }]">
<div class="actions"> <div class="actions">
<!-- <div class="action new" @click="addNewConv"><FormOutlined /></div> --> <!-- <div class="action new" @click="addNewConv"><FormOutlined /></div> -->
<span style="font-weight: bold;">对话历史</span> <span style="font-weight: bold; user-select: none;">对话历史</span>
<div class="action close" @click="state.isSidebarOpen = false"><MenuOutlined /></div> <div class="action close" @click="state.isSidebarOpen = false"><MenuOutlined /></div>
</div> </div>
<div class="conversation-list"> <div class="conversation-list">

View File

@ -7,7 +7,7 @@
<a-button type="text" danger class="del-db" @click="deleteDatabse"><DeleteOutlined /></a-button> <a-button type="text" danger class="del-db" @click="deleteDatabse"><DeleteOutlined /></a-button>
</div> </div>
<div class="top"> <div class="top">
<div class="icon"><ReadFilled /></div> <!-- <div class="icon"><ReadFilled /></div> -->
<div class="info"> <div class="info">
<h3>{{ database.name }}</h3> <h3>{{ database.name }}</h3>
<p><span>{{ database.metadata?.row_count }} · {{ database.files?.length || 0 }}文件</span></p> <p><span>{{ database.metadata?.row_count }} · {{ database.files?.length || 0 }}文件</span></p>
@ -15,17 +15,9 @@
</div> </div>
<p class="description">{{ database.description }}</p> <p class="description">{{ database.description }}</p>
<div class="tags"> <div class="tags">
<a-tag color="blue" v-if="database.embed_model">Embed: {{ database.embed_model }}</a-tag> <a-tag color="blue" v-if="database.embed_model">{{ database.embed_model }}</a-tag>
</div> </div>
<a-divider/> <a-divider/>
<div class="pagebtns">
<div @click="state.curPage='add'" :class="{ 'active': state.curPage === 'add' }">
<CloudUploadOutlined />添加文件
</div>
<div @click="state.curPage='query-test'" :class="{ 'active': state.curPage === 'query-test' }">
<SearchOutlined />检索测试
</div>
</div>
<div class="query-params" v-if="state.curPage == 'query-test'"> <div class="query-params" v-if="state.curPage == 'query-test'">
<p style="text-align: center; margin: 0;"><strong>参数配置</strong></p> <p style="text-align: center; margin: 0;"><strong>参数配置</strong></p>
<div class="params-item"> <div class="params-item">
@ -46,103 +38,113 @@
<div class="sider-bottom"> <div class="sider-bottom">
</div> </div>
</div> </div>
<div class="db-info-container" v-if="state.curPage == 'add'"> <a-tabs v-model:activeKey="state.curPage" class="atab-container" type="card">
<h3>向知识库中添加文件</h3> <a-tab-pane key="add">
<div class="upload"> <template #tab><span><CloudUploadOutlined />添加文件</span></template>
<a-upload-dragger <div class="db-info-container">
class="upload-dragger" <h3>向知识库中添加文件</h3>
v-model:fileList="fileList" <div class="upload">
name="file" <a-upload-dragger
:multiple="true" class="upload-dragger"
:disabled="state.loading" v-model:fileList="fileList"
action="/api/database/upload" name="file"
@change="handleFileUpload" :multiple="true"
@drop="handleDrop" :disabled="state.loading"
> action="/api/database/upload"
<p class="ant-upload-text">点击或者把文件拖拽到这里上传</p> @change="handleFileUpload"
<p class="ant-upload-hint"> @drop="handleDrop"
目前仅支持上传文本文件 .pdf, .txt, .md且同名文件无法重复添加 >
</p> <p class="ant-upload-text">点击或者把文件拖拽到这里上传</p>
</a-upload-dragger> <p class="ant-upload-hint">
</div> 目前仅支持上传文本文件 .pdf, .txt, .md且同名文件无法重复添加
<a-button </p>
type="primary" </a-upload-dragger>
@click="addDocumentByFile" </div>
:loading="state.loading" <a-button
:disabled="fileList.length === 0" type="primary"
style="margin: 0px 20px 20px 0;" @click="addDocumentByFile"
> :loading="state.loading"
添加到知识库 :disabled="fileList.length === 0"
</a-button> style="margin: 0px 20px 20px 0;"
<a-button @click="handleRefresh" :loading="state.refrashing">刷新状态</a-button> >
<a-table :columns="columns" :data-source="database.files" row-key="file_id" class="my-table"> 添加到知识库
<template #bodyCell="{ column, text, record }"> </a-button>
<template v-if="column.key === 'file_id'"> <a-button @click="handleRefresh" :loading="state.refrashing">刷新状态</a-button>
<a-button class="main-btn" type="link" @click="openFileDetail(record)">{{ text.toUpperCase() }}</a-button> <a-table :columns="columns" :data-source="database.files" row-key="file_id" class="my-table">
</template> <template #bodyCell="{ column, text, record }">
<template v-else-if="column.key === 'type'"><span :class="text">{{ text.toUpperCase() }}</span></template> <template v-if="column.key === 'file_id'">
<template v-else-if="column.key === 'status' && text === 'done'"> <a-button class="main-btn" type="link" @click="openFileDetail(record)">{{ text.toUpperCase() }}</a-button>
<CheckCircleFilled style="color: #41A317;"/> </template>
</template> <template v-else-if="column.key === 'type'"><span :class="text">{{ text.toUpperCase() }}</span></template>
<template v-else-if="column.key === 'status' && text === 'failed'"> <template v-else-if="column.key === 'status' && text === 'done'">
<CloseCircleFilled style="color: #FF4D4F ;"/> <CheckCircleFilled style="color: #41A317;"/>
</template> </template>
<template v-else-if="column.key === 'status' && text === 'processing'"> <template v-else-if="column.key === 'status' && text === 'failed'">
<HourglassFilled style="color: #1677FF;"/> <CloseCircleFilled style="color: #FF4D4F ;"/>
</template> </template>
<template v-else-if="column.key === 'status' && text === 'waiting'"> <template v-else-if="column.key === 'status' && text === 'processing'">
<ClockCircleFilled style="color: #FFCD43;"/> <HourglassFilled style="color: #1677FF;"/>
</template> </template>
<template v-else-if="column.key === 'action'"> <template v-else-if="column.key === 'status' && text === 'waiting'">
<a-button class="del-btn" type="link" <ClockCircleFilled style="color: #FFCD43;"/>
@click="deleteFile(text)" </template>
:disabled="state.lock || record.status == 'processing' || record.status == 'waiting' " <template v-else-if="column.key === 'action'">
>删除 <a-button class="del-btn" type="link"
</a-button> @click="deleteFile(text)"
</template> :disabled="state.lock || record.status == 'processing' || record.status == 'waiting' "
<span v-else-if="column.key === 'created_at'">{{ formatRelativeTime(Math.round(text*1000)) }}</span> >删除
<span v-else>{{ text }}</span> </a-button>
</template> </template>
</a-table> <span v-else-if="column.key === 'created_at'">{{ formatRelativeTime(Math.round(text*1000)) }}</span>
<a-drawer <span v-else>{{ text }}</span>
width="50%" </template>
v-model:open="state.drawer" </a-table>
class="custom-class" <a-drawer
:title="selectedFile?.filename" width="50%"
placement="right" v-model:open="state.drawer"
@after-open-change="afterOpenChange" class="custom-class"
> :title="selectedFile?.filename"
<h2> {{ selectedFile?.lines.length }} 个片段</h2> placement="right"
<p v-for="line in selectedFile?.lines" :key="line.id"> @after-open-change="afterOpenChange"
<strong>Chunk #{{ line.id }}</strong> {{ line.text }} >
</p> <h2> {{ selectedFile?.lines.length }} 个片段</h2>
</a-drawer> <p v-for="line in selectedFile?.lines" :key="line.id">
</div> <strong>Chunk #{{ line.id }}</strong> {{ line.text }}
<div class="db-info-container" v-else-if="state.curPage == 'query-test'"> </p>
<h3>检索测试</h3> </a-drawer>
<div class="query-action">
<a-textarea
v-model:value="queryText"
placeholder="填写需要查询的句子"
:auto-size="{ minRows: 2, maxRows: 10 }"
/>
<!-- :loading="state.searchLoading" -->
<a-button @click="onQuery" :disabled="queryText.length == 0" :loading="state.searchLoading">
<SearchOutlined v-if="!state.searchLoading"/>检索
</a-button>
</div>
<div class="query-test" v-if="queryResult">
<div class="query-card" v-for="(result, idx) in (meta.filter ? queryResult.results : queryResult.all_results)" :key="idx">
<p>
<strong>#{{ idx + 1 }}&nbsp;&nbsp;&nbsp;</strong>
<span>{{ result.file.filename }}&nbsp;&nbsp;&nbsp;</span>
<span><strong>距离</strong>{{ result.distance.toFixed(4) }}&nbsp;&nbsp;&nbsp;</span>
<span v-if="result.rerank_score"><strong>重排序</strong>{{ result.rerank_score.toFixed(4) }}</span>
</p>
<p class="query-text">{{ result.entity.text }}</p>
</div> </div>
</div> </a-tab-pane>
</div> <a-tab-pane key="query-test" force-render>
<template #tab><span><SearchOutlined />检索测试</span></template>
<div class="db-info-container">
<h3>检索测试</h3>
<div class="query-action">
<a-textarea
v-model:value="queryText"
placeholder="填写需要查询的句子"
:auto-size="{ minRows: 2, maxRows: 10 }"
/>
<!-- :loading="state.searchLoading" -->
<a-button class="btn-query" @click="onQuery" :disabled="queryText.length == 0">
<span v-if="!state.searchLoading"><SearchOutlined /> 检索</span>
<span v-else><LoadingOutlined /></span>
</a-button>
</div>
<div class="query-test" v-if="queryResult">
<div class="query-card" v-for="(result, idx) in (meta.filter ? queryResult.results : queryResult.all_results)" :key="idx">
<p>
<strong>#{{ idx + 1 }}&nbsp;&nbsp;&nbsp;</strong>
<span>{{ result.file.filename }}&nbsp;&nbsp;&nbsp;</span>
<span><strong>距离</strong>{{ result.distance.toFixed(4) }}&nbsp;&nbsp;&nbsp;</span>
<span v-if="result.rerank_score"><strong>重排序</strong>{{ result.rerank_score.toFixed(4) }}</span>
</p>
<p class="query-text">{{ result.entity.text }}</p>
</div>
</div>
</div>
</a-tab-pane>
<!-- <a-tab-pane key="3" tab="Tab 3">Content of Tab Pane 3</a-tab-pane> -->
</a-tabs>
</div> </div>
</template> </template>
@ -160,6 +162,7 @@ import {
DeleteOutlined, DeleteOutlined,
CloudUploadOutlined, CloudUploadOutlined,
SearchOutlined, SearchOutlined,
LoadingOutlined
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
@ -476,8 +479,7 @@ onMounted(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 20px; margin-bottom: 20px;
padding-top: 10px; padding: 10px;
padding-bottom: 10px;
background-color: var(--main-light-5); background-color: var(--main-light-5);
border-bottom: 1px solid #E0EAFF; border-bottom: 1px solid #E0EAFF;
@ -485,42 +487,44 @@ onMounted(() => {
height: auto; height: auto;
font-size: 16px; font-size: 16px;
color: var(--c-text-light-1); color: var(--c-text-light-1);
padding-left: 8px;
padding-right: 8px;
} }
} }
} }
.pagebtns { // .pagebtns {
display: flex; // display: flex;
flex-direction: column; // flex-direction: column;
gap: 16px; // gap: 16px;
> div { // > div {
gap: 1rem; // gap: 1rem;
width: 100%; // width: 100%;
display: flex; // display: flex;
justify-content: center; // justify-content: center;
align-items: center; // align-items: center;
padding: 10px 16px; // padding: 10px 16px;
height: auto; // height: auto;
border-radius: 4px; // border-radius: 4px;
border: none; // border: none;
background: var(--main-light-5); // background: var(--main-light-5);
letter-spacing: 4px; // letter-spacing: 4px;
border-radius: 8px; // border-radius: 8px;
border: 1px solid var(--main-light-2); // border: 1px solid var(--main-light-2);
&:hover { // &:hover {
cursor: pointer; // cursor: pointer;
background: var(--main-light-3); // background: var(--main-light-3);
} // }
} // }
.active { // .active {
color: var(--main-color); // color: var(--main-color);
background: var(--main-light-3); // background: var(--main-light-3);
font-weight: bold; // font-weight: bold;
} // }
} // }
.query-params { .query-params {
display: flex; display: flex;
@ -548,10 +552,13 @@ onMounted(() => {
} }
} }
.atab-container {
padding: 12px 16px;
width: 100%;
}
.db-info-container { .db-info-container {
padding: 20px;
flex: 1 1 auto; flex: 1 1 auto;
overflow: scroll;
.query-action { .query-action {
display: flex; display: flex;
@ -563,13 +570,13 @@ onMounted(() => {
border: 1px solid var(--main-light-2); border: 1px solid var(--main-light-2);
} }
button { button.btn-query {
height: auto; height: auto;
width: 120px; width: 100px;
box-shadow: none; box-shadow: none;
border: none; border: none;
font-weight: bold; font-weight: bold;
background: var(--main-light-2); background: var(--main-light-3);
color: var(--main-color); color: var(--main-color);
&:disabled { &:disabled {
@ -626,6 +633,24 @@ onMounted(() => {
margin: 0; margin: 0;
} }
h3 {
font-size: 20px;
font-weight: bold;
&::after {
content: '';
position: absolute;
bottom: 0px;
width: 100%;
height: 10px;
background: var(--main-color);
display: block;
opacity: 0.5;
z-index: -1;
}
}
p { p {
color: var(--c-text-light-1); color: var(--c-text-light-1);
font-size: small; font-size: small;

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="database-container" v-if="configStore.config.enable_knowledge_base"> <div class="database-container layout-container" v-if="configStore.config.enable_knowledge_base">
<h2>文档知识库</h2> <h2>文档知识库</h2>
<p>知识型数据库主要是非结构化的文本组成使用向量检索使用</p> <p>知识型数据库主要是非结构化的文本组成使用向量检索使用</p>
<a-modal :open="newDatabase.open" title="新建数据库" @ok="createDatabase"> <a-modal :open="newDatabase.open" title="新建数据库" @ok="createDatabase">
@ -49,7 +49,7 @@
<!-- <button @click="deleteDatabase(database.collection_name)">删除</button> --> <!-- <button @click="deleteDatabase(database.collection_name)">删除</button> -->
</div> </div>
</div> </div>
<h2>图数据库 &nbsp; <a-spin v-if="graphloading" :indicator="indicator" /></h2> <!-- <h2>图数据库 &nbsp; <a-spin v-if="graphloading" :indicator="indicator" /></h2>
<p>基于 neo4j 构建的图数据库</p> <p>基于 neo4j 构建的图数据库</p>
<div :class="{'graphloading': graphloading, 'databases': true}" v-if="graph"> <div :class="{'graphloading': graphloading, 'databases': true}" v-if="graph">
<div class="dbcard graphbase" @click="navigateToGraph"> <div class="dbcard graphbase" @click="navigateToGraph">
@ -64,9 +64,8 @@
</div> </div>
</div> </div>
<p class="description">基于 neo4j 构建的图数据库基于 neo4j 构建的图数据库基于 neo4j 构建的图数据库</p> <p class="description">基于 neo4j 构建的图数据库基于 neo4j 构建的图数据库基于 neo4j 构建的图数据库</p>
<!-- <button @click="deleteDatabase(database.collection_name)">删除</button> -->
</div> </div>
</div> </div> -->
</div> </div>
<div class="database-empty" v-else> <div class="database-empty" v-else>
<a-empty> <a-empty>
@ -103,7 +102,7 @@ const newDatabase = reactive({
}) })
const loadDatabases = () => { const loadDatabases = () => {
loadGraph() // loadGraph()
fetch('/api/database/', { fetch('/api/database/', {
method: "GET", method: "GET",
}) })
@ -154,23 +153,23 @@ const navigateToGraph = () => {
router.push({ path: `/database/graph` }); router.push({ path: `/database/graph` });
}; };
const loadGraph = () => { // const loadGraph = () => {
graphloading.value = true // graphloading.value = true
fetch('/api/database/graph', { // fetch('/api/database/graph', {
method: "GET", // method: "GET",
}) // })
.then(response => response.json()) // .then(response => response.json())
.then(data => { // .then(data => {
console.log(data) // console.log(data)
graph.value = data.graph // graph.value = data.graph
graphloading.value = false // graphloading.value = false
}) // })
.catch(error => { // .catch(error => {
console.error(error) // console.error(error)
message.error(error.message) // message.error(error.message)
graphloading.value = false // graphloading.value = false
}) // })
} // }
watch(() => route.path, (newPath, oldPath) => { watch(() => route.path, (newPath, oldPath) => {
if (newPath === '/database') { if (newPath === '/database') {
@ -185,14 +184,6 @@ onMounted(() => {
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.database-container {
padding: 10px 30px;
background-color: #FCFEFF;
h2 {
margin: 20px 0 10px 0;
}
}
.database-actions, .document-actions { .database-actions, .document-actions {
margin-bottom: 20px; margin-bottom: 20px;
} }
@ -278,10 +269,9 @@ onMounted(() => {
} }
// //
.graphloading { // .graphloading {
filter: blur(2px); // filter: blur(2px);
} // }
.database-empty { .database-empty {
display: flex; display: flex;

View File

@ -1,9 +1,24 @@
<template> <template>
<div class="graph-container"> <div class="database-empty" v-if="!state.showPage">
<a-empty>
<template #description>
<span>
前往 <router-link to="/setting" style="color: var(--main-color); font-weight: bold;">设置</router-link>
</span>
</template>
</a-empty>
</div>
<div class="graph-container layout-container" v-else>
<div class="info"> <div class="info">
<h1>Neo4j 图数据库</h1> <h2>图数据库 {{ graph?.database_name }}</h2>
<p>基于 Neo4j 构建的图数据库</p> <p>
</div> <span v-if="state.graphloading">加载中</span>
<span class="green-dot" v-if="graph?.status == 'open'"></span>
<span class="red-dot" v-else></span>
<span>{{ graph?.status }}</span> ·
<span> {{ graph?.entity_count }} 实体{{ graph?.relationship_count }} 个关系</span>
</p>
</div>
<div class="actions"> <div class="actions">
<div class="actions-left"> <div class="actions-left">
<a-button @click="state.showModal = true">上传文件</a-button> <a-button @click="state.showModal = true">上传文件</a-button>
@ -32,6 +47,8 @@
</a-upload-dragger> </a-upload-dragger>
</div> </div>
</a-modal> </a-modal>
<input v-model="sampleNodeCount">
<a-button @click="loadSampleNodes">确定</a-button>
</div> </div>
<div class="action-right"> <div class="action-right">
<input <input
@ -49,8 +66,7 @@
</a-button> </a-button>
</div> </div>
</div> </div>
<div class="main" id="container"></div> <div class="main" id="container" ref="container"></div>
</div> </div>
</template> </template>
@ -58,34 +74,47 @@
import { Graph } from "@antv/g6"; import { Graph } from "@antv/g6";
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { message } from "ant-design-vue"; import { message } from "ant-design-vue";
import { useConfigStore } from '@/stores/config';
const configStore = useConfigStore()
let graphInstance let graphInstance
const graph = ref(null)
const container = ref(null);
const fileList = ref([]); const fileList = ref([]);
const sampleNodeCount = ref(100);
const subgraph = reactive({ const subgraph = reactive({
nodes: [ nodes: [],
{ id: '1', name: 'node1' }, edges: [],
{ id: '2', name: 'node2' },
{ id: '3', name: 'node3' },
{ id: '4', name: 'node4' },
{ id: '5', name: 'node5' },
],
edges: [
{ id: 'e1', source_id: '1', target_id: '2', type: 'edge1' },
{ id: 'e2', source_id: '1', target_id: '3', type: 'edge2' },
{ id: 'e3', source_id: '2', target_id: '4', type: 'edge3' },
{ id: 'e4', source_id: '2', target_id: '5', type: 'edge4' },
],
}); });
const state = reactive({ const state = reactive({
graphloading: false,
searchInput: '', searchInput: '',
searchLoading: false, searchLoading: false,
showModal: false, showModal: false,
precessing: false, precessing: false,
showPage: computed(() => configStore.config.enable_knowledge_base && configStore.config.enable_knowledge_graph),
}) })
const getCurWidth = () => document.getElementById("container").offsetWidth
const getCurHeight = () => document.getElementById("container").offsetHeight const loadGraph = () => {
state.graphloading = true
fetch('/api/database/graph', {
method: "GET",
})
.then(response => response.json())
.then(data => {
console.log(data)
graph.value = data.graph
state.graphloading = false
})
.catch(error => {
console.error(error)
message.error(error.message)
state.graphloading = false
})
}
const graphData = computed(() => { const graphData = computed(() => {
return { return {
@ -129,6 +158,29 @@ const addDocumentByFile = () => {
.finally(() => state.precessing = false) .finally(() => state.precessing = false)
}; };
const loadSampleNodes = () => {
fetch(`/api/database/graph/nodes?kgdb_name=neo4j&num=${sampleNodeCount.value}`)
.then((res) => {
if (res.ok) {
return res.json();
} else {
throw new Error("加载失败");
}
})
.then((data) => {
subgraph.nodes = data.result.nodes
subgraph.edges = data.result.edges
console.log(data)
console.log(subgraph)
setTimeout(() => {
randerGraph()
}, 500)
})
.catch((error) => {
message.error(error.message);
})
}
const onSearch = () => { const onSearch = () => {
if (!state.searchInput) { if (!state.searchInput) {
message.error('请输入要查询的实体') message.error('请输入要查询的实体')
@ -162,44 +214,50 @@ const randerGraph = () => {
graphInstance.render(); graphInstance.render();
} }
onMounted(() => { onMounted(() => {
graphInstance = new Graph({ loadGraph();
container: document.getElementById("container"), loadSampleNodes();
width: getCurWidth(), setTimeout(() => {
height: getCurHeight(), if (state.showPage) {
autoFit: true, graphInstance = new Graph({
autoResize: true, container: container.value,
layout: { width: container.value.offsetWidth,
type: 'force-atlas2', height: container.value.offsetHeight,
preventOverlap: true, autoFit: true,
kr: 100, autoResize: true,
}, layout: {
node: { type: 'd3-force',
type: 'circle', preventOverlap: true,
style: { kr: 100,
labelText: (d) => d.data.label, collide: {
size: 40, strength: 0.5,
}, },
palette: { },
field: 'label', node: {
color: 'tableau', type: 'circle',
}, style: {
}, labelText: (d) => d.data.label,
edge: { size: 40,
type: 'line', },
style: { palette: {
labelText: (d) => d.data.label, field: 'label',
labelBackground: '#fff', color: 'tableau',
}, },
}, },
behaviors: ['drag-element'], edge: {
}); type: 'line',
graphInstance.setData(graphData.value); style: {
graphInstance.render(); labelText: (d) => d.data.label,
window.addEventListener('resize', randerGraph); labelBackground: '#fff',
},
},
behaviors: ['drag-element', 'zoom-canvas', 'drag-canvas'],
});
graphInstance.setData(graphData.value);
graphInstance.render();
window.addEventListener('resize', randerGraph);
}
}, 400)
}); });
@ -217,19 +275,46 @@ const handleDrop = (event) => {
<style lang="less" scoped> <style lang="less" scoped>
.graph-container { .graph-container {
padding: 20px;
.info span.green-dot, .info span.red-dot {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 50%;
margin: 0 5px;
}
.info span.green-dot {
background: #52c41a;
}
.info span.red-dot {
background: #f5222d;
}
.info {
margin-bottom: 20px;
}
} }
.actions { .actions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 20px; margin-bottom: 20px;
.actions-left {
display: flex;
align-items: center;
gap: 10px;
}
input { input {
width: 100px;
margin-right: 10px; margin-right: 10px;
border-radius: 8px; border-radius: 8px;
padding: 4px 12px; padding: 4px 12px;
border: 2px solid #d9d9d9; border: 2px solid var(--main-300);
outline: none; outline: none;
height: 42px; height: 42px;
@ -253,17 +338,22 @@ const handleDrop = (event) => {
} }
} }
#container { #container {
background: #F7F7F7; background: #F7F7F7;
margin: 20px 0; margin: 20px 0;
border-radius: 16px; border-radius: 16px;
width: 100%; width: 100%;
height: calc(100% - 200px); height: 800px;
resize: horizontal;
} }
.database-empty {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
flex-direction: column;
color: var(--c-text-light-1);
}
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="setting-container"> <div class="setting-container layout-container">
<div class="setting"> <div class="setting">
<h2>设置</h2> <h2>设置</h2>
<h3>模型配置</h3> <h3>模型配置</h3>
@ -88,13 +88,13 @@
@change="handleChange('enable_knowledge_base', !configStore.config.enable_knowledge_base)" @change="handleChange('enable_knowledge_base', !configStore.config.enable_knowledge_base)"
/> />
</div> </div>
<!-- <div class="card"> <div class="card">
<span class="label">{{ items?.enable_knowledge_graph.des }}</span> <span class="label">{{ items?.enable_knowledge_graph.des }}</span>
<a-switch <a-switch
:checked="configStore.config.enable_knowledge_graph" :checked="configStore.config.enable_knowledge_graph"
@change="handleChange('enable_knowledge_graph', !configStore.config.enable_knowledge_graph)" @change="handleChange('enable_knowledge_graph', !configStore.config.enable_knowledge_graph)"
/> />
</div> --> </div>
<div class="card"> <div class="card">
<span class="label">{{ items?.enable_search_engine.des }}</span> <span class="label">{{ items?.enable_search_engine.des }}</span>
<a-switch <a-switch
@ -136,6 +136,16 @@ const state = reactive({
}) })
const handleChange = (key, e) => { const handleChange = (key, e) => {
if (key == 'enable_knowledge_graph' && e && !configStore.config.enable_knowledge_base) {
message.error('启动知识图谱必须请先启用知识库功能')
return
}
if (key == 'enable_knowledge_base' && !e && configStore.config.enable_knowledge_graph) {
message.error('关闭知识库功能必须请先关闭知识图谱功能')
return
}
console.log('Change', key, e) console.log('Change', key, e)
needRestart[key] = true needRestart[key] = true
configStore.setConfigValue(key, e) configStore.setConfigValue(key, e)
@ -156,10 +166,6 @@ const sendRestart = () => {
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.setting-container {
width: 100%;
padding: 20px;
}
.setting { .setting {
max-width: 800px; max-width: 800px;