diff --git a/.gitignore b/.gitignore index 439e3613..83a0cd7a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,9 +33,10 @@ src/data */package-lock.json web/package-lock.json saves +saves_dev notebooks graphrag docker/volumes - - .cursorrules +src/static/config.yaml +src/static/config.dev.yaml diff --git a/LICENSE b/LICENSE index 3ba15384..b5064ccb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Wenjie Zhang +Copyright (c) 2025 Wenjie Zhang Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 49cf70c6..85b0e2b2 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,57 @@ -

语析 (基于大模型的知识图谱问答平台)

+

语析 (基于大模型的知识库+知识图谱问答平台)

![](https://img.shields.io/badge/Docker-2496ED?style=flat&logo=docker&logoColor=ffffff) -![Vue.js](https://img.shields.io/badge/vuejs-%2335495e.svg?style=flat&logo=vuedotjs&logoColor=%234FC08D) +![Vue.js](https://img.shields.io/badge/vuejs-%2335495e.svg?style=flat&logo=vuedotjs&logoColor=%234FC08D)  ![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=flat&logo=fastapi) ![](https://img.shields.io/github/issues/xerrors/Yuxi-Know?color=F48D73) ![](https://img.shields.io/github/license/bitcookies/winrar-keygen.svg?logo=github) +![](https://img.shields.io/github/stars/xerrors/Yuxi-Know)
-> [!NOTE] -> 当前项目还处于开发的早期,还存在一些 BUG,有问题随时提 issue。 - ## 概述 基于大模型 RAG 知识库与知识图谱的问答平台。Llamaindex + VueJS + Flask + Neo4j。大模型适配 OpenAI、国内主流大模型平台的模型调用、本地 vllm 部署。只需要配置对应服务平台的 `API_KEY` 即可使用。 -![main](./images/main.png) +![image](https://github.com/user-attachments/assets/75010511-4ac5-4924-8268-fea9a589839c) + + + +> [!NOTE] +> 当前项目还处于开发的早期,还存在一些 BUG,有问题随时提 issue。 + +代办清单 + +- [ ] Ollma Embedding 支持(Open-like Embedding 支持) +- [x] 知识图谱索引支持自定义 Embedding 模型 +- [x] DeepSeek-R1 支持 + +## 更新日志 + +- 2025.02.24 新增网页检索以及内容展示,需要配置 `TAVILY_API_KEY`, 感谢 [littlewwwhite](https://github.com/littlewwwhite)。 +- 2025.02.23 SiliconFlow 的 Rerank 和 Embedding model 支持。现在默认使用 SiliconFlow。 +- 2025.02.20 DeepSeek-R1 支持,需配置 `DEEPSEEK_API_KEY` 或者 `SILICONFLOW_API_KEY` 使用 +- 2024.10.12 后端修改为 [FastAPI](https://github.com/fastapi),并添加了 [Milvus-Standalone](https://github.com/milvus-io) 的独立部署。 + +![image](https://github.com/user-attachments/assets/8416a933-cc43-45d0-bf06-00df0ba6c4fb) + ## 快速上手 -在启动之前,提供 API 服务商的 API_KEY,并放置在 `src/.env` 文件中。默认使用的是智谱AI。因此务必需要配置 `ZHIPUAI_API_KEY=`。其余模型的配置可以参考 [src/config/models.yaml](src/config/models.yaml) 中的 env。 +在启动之前,提供 API 服务商的 API_KEY,并放置在 `src/.env` 文件中。默认使用的是硅基流动。因此务必需要配置 `SILICONFLOW_API_KEY=`。其余模型的配置可以参考 [src/static/models.yaml](src/static/models.yaml) 中的 env。 ``` -ZHIPUAI_API_KEY=270ea********8bfa97.e3XOMd****Q1Sk -OPENAI_API_KEY=sk-*********[可选] +SILICONFLOW_API_KEY=sk-270ea********8bfa97.e3XOMd****Q1Sk ``` -本项目的基础对话服务可以在不含显卡的设备上运行,大模型使用在线服务商的接口。但是如果想要完整的知识库对话体验,则需要 8G 以上的显存。因为需要本地运行 embedding 模型和 rerank 模型。 +本项目的基础对话服务可以在不含显卡的设备上运行,大模型使用在线服务商的接口。如果需要指定本地模型(如 Embedding、Rerank 模型)所在路径,需要配置 `MODEL_DIR` 参数。并在 Docker Compose 中启用映射。 **提醒**:下面的脚本会启动开发版本,源代码的修改会自动更新(含前端和后端)。如果生产环境部署,请使用 `docker/docker-compose.yml` 启动。 ```bash -docker-compose -f docker/docker-compose.dev.yml up --build +docker compose -f docker/docker-compose.dev.yml --env-file src/.env up --build ``` **也可以加上 `-d` 参数,后台运行。* @@ -60,7 +78,7 @@ docker-compose -f docker/docker-compose.dev.yml up --build 关闭 docker 服务: ```bash -docker-compose -f docker/docker-compose.dev.yml down +docker compose -f docker/docker-compose.dev.yml --env-file src/.env down ``` 查看日志: @@ -69,73 +87,64 @@ docker-compose -f docker/docker-compose.dev.yml down docker logs # 例如:docker logs api-dev ``` -如果需要使用到本地模型(不推荐手动指定),比如向量模型或者重排序模型,则需要将环境变量中设置的 `MODEL_ROOT_DIR` 做映射,比如本地模型都是存放在 `/hdd/models` 里面,则需要在 `docker-compose.yml` 和 `docker-compose.dev.yml` 中添加: - -```yml -services: - api: - build: - context: .. - dockerfile: docker/api.Dockerfile - container_name: api-dev - working_dir: /app - volumes: - - ../src:/app/src - - ../saves:/app/saves - - /hdd/zwj/models:/hdd/zwj/models # <== 修改这一行 -``` - **生产环境部署**:本项目同时支持使用 Docker 部署生产环境,只需要更换 `docker-compose` 文件就可以了。 ```bash -docker-compose -f docker/docker-compose.yml up --build +docker compose -f docker/docker-compose.yml --env-file src/.env up --build ``` +注:如果想要在本地使用 PyCharm 等 IDE 加断点调试,可以使用 Docker 运行除 API 以外的容器,然后本地运行 main.py,但是需要注意将对应的环境变量添加到 .env 文件中 + ## 模型支持 ### 1. 对话模型支持 -模型仅支持通过API调用的模型,如果是需要运行本地模型,则建议使用 vllm 转成 API 服务之后使用。使用前请在 `.env` 配置 APIKEY 后使用,配置项目参考:[src/config/models.yaml](src/config/models.yaml) +模型仅支持通过API调用的模型,如果是需要运行本地模型,则建议使用 vllm 转成 API 服务之后使用。使用前请在 `.env` 配置 APIKEY 后使用,配置项目参考:[src/static/models.yaml](src/static/models.yaml) | 模型供应商 | 默认模型 | 配置项目 | | :-------------------- | :---------------------------------------- | :--------------------------------------------- | +| `siliconflow` (默认) | `Qwen/Qwen2.5-7B-Instruct` (免费) | `SILICONFLOW_API_KEY` | | `openai` | `gpt-4o` | `OPENAI_API_KEY` | -| `qianfan`(百度) | `ernie_speed` | `QIANFAN_ACCESS_KEY`, `QIANFAN_SECRET_KEY` | -| `zhipu`(默认) | `glm-4-flash` (免费) | `ZHIPUAI_API_KEY` | -| `dashscope`(阿里) | `qwen-max-latest` | `DASHSCOPE_API_KEY` | | `deepseek` | `deepseek-chat` | `DEEPSEEK_API_KEY` | -| `siliconflow` | `meta-llama/Meta-Llama-3.1-8B-Instruct` | `SILICONFLOW_API_KEY` | +| `arc`(豆包方舟) | `doubao-1-5-pro-32k-250115` | `ARK_API_KEY` | +| `zhipu`(智谱清言) | `glm-4-flash` | `ZHIPUAI_API_KEY` | +| `dashscope`(阿里) | `qwen-max-latest` | `DASHSCOPE_API_KEY` | +| `qianfan`(百度) | `ernie_speed` | `QIANFAN_ACCESS_KEY`, `QIANFAN_SECRET_KEY` | -同样支持以 OpenAI 的兼容模型运行模型,可以直接在 Web 设置里面添加。比如使用 vllm 和 Ollama 运行本地模型时。 +此外,如果想要添加供应商的模型,确认知识 OpenAI 调用的方法之后,只需要在 [src/static/models.yaml](src/static/models.yaml) 中添加对应的模型配置即可。配置示例如下: -### 2. 向量模型支持 +```yaml + ark: + name: 豆包(Ark) + url: https://console.volcengine.com/ark/region:ark+cn-beijing/model # 模型列表 + default: doubao-1-5-pro-32k-250115 # 默认模型 + base_url: https://ark.cn-beijing.volces.com/api/v3 + env: # 需要配置的环境变量,仅限API key + - ARK_API_KEY + models: + - doubao-1-5-pro-32k-250115 + - doubao-1-5-lite-32k-250115 + - deepseek-r1-250120 +``` -建议直接使用智谱 AI 的 embedding-3,这样不需要做任何修改,且资费不贵。 +同样支持添加以 OpenAI 的兼容模式运行的单个模型,可以直接在 Web 设置里面添加。比如使用 vllm 和 Ollama 运行本地模型时。 + +![](./images/custom_models.png) + +### 2. 向量模型支持和重排序模型支持 + +建议直接使用硅基流动部署的 bge-m3,这样不需要做任何修改,且免费。其余模型参考:[src/static/models.yaml](src/static/models.yaml) > [!Warning] > 需要注意,由于知识库和图数据库的构建都依赖于向量模型,如果中途更改向量模型,会导致知识库不可用。此外,知识图谱的向量索引的建立默认使用 embedding-3 构建,因此检索的时候必须使用 embedding-3(现阶段还不支持修改) -| 模型名称(`config.embed_model`) | 默认路径/模型 | 需要配置项目(`config.model_local_paths`) | -| :------------------------------- | :------------------------------- | :------------------------------------------- | -| `bge-large-zh-v1.5` | `BAAI/bge-large-zh-v1.5` | `bge-large-zh-v1.5` | -| `zhipu` | `embedding-2`, `embedding-3` | `ZHIPUAI_API_KEY` (`.env`) | - -### 3. 重排序模型支持 - -目前仅支持 `BAAI/bge-reranker-v2-m3`。 - -### 4. 本地模型支持 对于**语言模型**,并不支持直接运行本地语言模型,请使用 vllm 或者 ollama 转成 API 服务之后使用。 -对于**向量模型**和**重排序模型**,可以不做修改会自动下载模型,如果下载过程中出现问题,请参考 [HF-Mirror](https://hf-mirror.com/) 配置相关内容。如果想要使用本地已经下载好的模型(不建议),可以在 `saves/config/config.yaml` 配置相关内容。同时注意要在 docker 中做映射,参考 README 中的 `docker/docker-compose.yml`。 +对于**向量模型**和**重排序模型**,选择以 `local` 前缀开头的模型,可以不做修改会自动下载模型,如果下载过程中出现问题,请参考 [HF-Mirror](https://hf-mirror.com/) 配置相关内容。(但请注意,如果是 Docker 运行,模型仅会缓存到 Docker 里面) -例如: +如果想要使用本地已经下载好的模型,可以在网页的 settings 里面做映射。或者修改 `src/static/config.yaml` 来配置映射关系。但请记得,本地模型的路径要在 docker-compose 的文件中映射 volumes。 -```yaml -model_local_paths: - bge-large-zh-v1.5: /models/bge-large-zh-v1.5 -``` ## 知识库支持 @@ -152,10 +161,6 @@ model_local_paths: 目前项目中暂不支持同时查询多个知识图谱,短期内也没有计划支持。不过倒是可以通过配置不同的 `NEO4J_URI` 服务来切换知识图谱。如果已经有了基于 neo4j 的知识图谱,可以将 `docker-compose.yml` 中的 `graph` 配置项删除,并将 `api.environment` 中的 `NEO4J_URI` 配置项修改为 neo4j 的服务地址。 -## 更新日志 - -- 2024.10.12 后端修改为 [FastAPI](https://github.com/fastapi),并添加了 [Milvus-Standalone](https://github.com/milvus-io) 的独立部署。 - ## 相关问题 ### 镜像下载问题 diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile index 31b41002..068bdc63 100644 --- a/docker/api.Dockerfile +++ b/docker/api.Dockerfile @@ -1,5 +1,5 @@ # 使用基础镜像 -FROM pytorch/pytorch:2.4.1-cuda11.8-cudnn9-runtime +FROM python:3.12 # 设置工作目录 WORKDIR /app @@ -9,8 +9,10 @@ COPY ../requirements.txt /app/requirements.txt # 安装依赖(Docker 会缓存这一步,除非 requirements.txt 发生变化) RUN pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple +RUN pip install -U gunicorn -i https://pypi.tuna.tsinghua.edu.cn/simple -RUN pip install gunicorn +RUN apt-get clean +RUN apt-get update && apt-get install ffmpeg libsm6 libxext6 -y # 复制代码到容器中 COPY ../src /app/src diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 740985b6..bd08549f 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -7,10 +7,11 @@ services: working_dir: /app volumes: - ../src:/app/src - - ../saves:/app/saves - - /hdd/zwj/models:/hdd/zwj/models + - ../saves_dev:/app/saves + - ../src/static/config.dev.yaml:/app/src/static/config.yaml + # - ${MODEL_DIR}:${MODEL_DIR} # 如果出现 undefined volume MODEL_DIR: invalid compose project,请添加此环境变量或者注释此行 ports: - - "5000:5000" + - "5050:5050" depends_on: - graph - milvus @@ -21,7 +22,8 @@ services: - NEO4J_USERNAME=neo4j - NEO4J_PASSWORD=0123456789 - MILVUS_URI=http://milvus:19530 - command: uvicorn src.main:app --host 0.0.0.0 --port 5000 --reload + - MODEL_DIR=${MODEL_DIR} # 优先级高于 .env 中的 MODEL_DIR + command: uvicorn src.main:app --host 0.0.0.0 --port 5050 --reload web: build: @@ -40,7 +42,7 @@ services: - app-network environment: - NODE_ENV=development - - VITE_API_URL=http://api:5000 # 添加这行 + - VITE_API_URL=http://api:5050 # 添加这行 command: npm run server graph: diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index baeff494..20737eb3 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -1,12 +1,13 @@ # 开发阶段 -FROM node:22.10.0 AS development +FROM node:latest AS development WORKDIR /app # 复制 package.json 和 package-lock.json(如果存在) COPY ./web/package*.json ./ # 安装依赖 -RUN npm install --registry https://registry.npmmirror.com --verbose --force +RUN npm install --verbose --force +# RUN npm install --registry http://mirrors.cloud.tencent.com/npm/ --verbose --force # 复制源代码 COPY ./web . @@ -17,11 +18,12 @@ EXPOSE 5173 # 启动开发服务器的命令在 docker-compose 文件中定义 # 生产阶段 -FROM node:22.10.0 AS build-stage +FROM node:latest AS build-stage WORKDIR /app COPY ./web/package*.json ./ -RUN npm install --registry https://registry.npmmirror.com --force +RUN npm install --force +# RUN npm install --registry https://registry.npmmirror.com --force COPY ./web . RUN npm run build diff --git a/images/custom_models.png b/images/custom_models.png new file mode 100644 index 00000000..24e8aa62 Binary files /dev/null and b/images/custom_models.png differ diff --git a/images/reasoning.png b/images/reasoning.png new file mode 100644 index 00000000..54522a06 Binary files /dev/null and b/images/reasoning.png differ diff --git a/requirements.txt b/requirements.txt index c7f87bf4..fb29a1b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ dashscope>=1.20.3 PyMuPDF==1.23.26 peft>=0.11.1 -FlagEmbedding>=1.2.11 +FlagEmbedding>=1.3.2 Flask>=3.0.3 Flask_Cors>=4.0.1 llama_index>=0.11.8 @@ -18,7 +18,7 @@ torch>=2.4.0 tqdm>=4.66.1 zhipuai>=2.1.2 neo4j>=5.22.0 -sentencepiece==0.1.99 +sentencepiece>=0.1.99 llama-index-readers-file opencv-python-headless docx2txt diff --git a/scripts/init.sh b/scripts/init.sh deleted file mode 100644 index 9f6b6881..00000000 --- a/scripts/init.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -# 检查是否提供了 API_KEY 参数 -if [ -z "$1" ]; then - echo "请提供 API_KEY。" - exit 1 -fi - -# 获取当前目录路径 -CURRENT_DIR=$(pwd) - -# 如果 src 目录不存在则创建 -if [! -d "${CURRENT_DIR}/src" ]; then - mkdir -p "${CURRENT_DIR}/src" -fi - -# 如果.env 文件不存在,则从.env.template 复制一份创建 -if [! -f "${CURRENT_DIR}/src/.env" ]; then - cp "${CURRENT_DIR}/src/.env.template" "${CURRENT_DIR}/src/.env" -fi - -# 将 API_KEY 写入.env 文件 -echo "ZHIPUAI_API_KEY=$1" > "${CURRENT_DIR}/src/.env" - -echo "API_KEY 已成功写入 src/.env 文件。" \ No newline at end of file diff --git a/scripts/run_docker.sh b/scripts/run_docker.sh new file mode 100644 index 00000000..0007809e --- /dev/null +++ b/scripts/run_docker.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# 检查是否提供了命令行参数 +if [ $# -eq 0 ]; then + echo "请提供命令参数,例如:up -d 或 down" + exit 1 +fi + +# 将所有命令行参数传递给 docker compose 命令 +docker compose -f docker/docker-compose.dev.yml --env-file src/.env "$@" \ No newline at end of file diff --git a/src/.env.template b/src/.env.template index 0774d24d..cb3234d8 100644 --- a/src/.env.template +++ b/src/.env.template @@ -1 +1,3 @@ -ZHIPUAI_API_KEY= +ZHIPUAI_API_KEY=270ea71e9******97.e3XOMdWKuZb7Q1Sk +SILICONFLOW_API_KEY=sk-aeoamptic****vdmozbpdsofstmoqnvsmbvql +MODEL_DIR=/home/user/Documents/projects/Yuxi-Know/models \ No newline at end of file diff --git a/src/config/__init__.py b/src/config/__init__.py index 236e6f3b..bbd959f2 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -6,7 +6,7 @@ from src.utils.logging_config import setup_logger logger = setup_logger("Config") -with open(Path("src/config/models.yaml"), "r") as f: +with open(Path("src/static/models.yaml"), "r") as f: _models = yaml.safe_load(f) MODEL_NAMES = _models["MODEL_NAMES"] @@ -40,34 +40,35 @@ class SimpleConfig(dict): class Config(SimpleConfig): - def __init__(self, filename=None): + def __init__(self): super().__init__() self._config_items = {} + self.save_dir = "saves" + self.filename = str(Path("src/static/config.yaml")) + os.makedirs(os.path.dirname(self.filename), exist_ok=True) ### >>> 默认配置 # 可以在 config/base.yaml 中覆盖 self.add_item("stream", default=True, des="是否开启流式输出") - self.add_item("save_dir", default="saves", des="保存目录") # 功能选项 self.add_item("enable_reranker", 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_web_search", default=False, des="是否开启网页搜索") + self.add_item("enable_web_search", default=False, des="是否开启网页搜索(需配置 TAVILY_API_KEY)") # 模型配置 ## 注意这里是模型名,而不是具体的模型路径,默认使用 HuggingFace 的路径 - ## 如果需要自定义路径,则在 config/base.yaml 中配置 model_local_paths - self.add_item("model_provider", default="zhipu", des="模型提供商", choices=list(MODEL_NAMES.keys())) - self.add_item("model_name", default=None, des="模型名称") - self.add_item("embed_model", default="zhipu-embedding-3", des="Embedding 模型", choices=list(EMBED_MODEL_INFO.keys())) - self.add_item("reranker", default="bge-reranker-v2-m3", des="Re-Ranker 模型", choices=list(RERANKER_LIST.keys())) - self.add_item("use_rewrite_query", default="off", des="重写查询", choices=["off", "on", "hyde"]) + ## 如果需要自定义本地模型路径,则在 src/.env 中配置 MODEL_DIR + self.add_item("model_provider", default="siliconflow", des="模型提供商", choices=list(MODEL_NAMES.keys())) + self.add_item("model_provider_lite", default="siliconflow", des="模型提供商(用于轻量任务)", choices=list(MODEL_NAMES.keys())) + self.add_item("model_name", default="Qwen/Qwen2.5-7B-Instruct", des="模型名称") + self.add_item("model_name_lite", default="Qwen/Qwen2.5-7B-Instruct", des="模型名称(用于轻量任务)") + self.add_item("embed_model", default="siliconflow/BAAI/bge-m3", des="Embedding 模型", choices=list(EMBED_MODEL_INFO.keys())) + self.add_item("reranker", default="siliconflow/BAAI/bge-reranker-v2-m3", des="Re-Ranker 模型", choices=list(RERANKER_LIST.keys())) self.add_item("model_local_paths", default={}, des="本地模型路径") + self.add_item("use_rewrite_query", default="off", des="重写查询", choices=["off", "on", "hyde"]) ### <<< 默认配置结束 - self.filename = filename or os.path.join(self.save_dir, "config", "config.yaml") - os.makedirs(os.path.dirname(self.filename), exist_ok=True) - self.load() self.handle_self() @@ -90,7 +91,9 @@ class Config(SimpleConfig): def handle_self(self): self.model_names = MODEL_NAMES model_provider_info = self.model_names.get(self.model_provider, {}) + self.model_dir = os.environ.get("MODEL_DIR", "") + # 检查模型提供商是否存在 if self.model_provider != "custom": if self.model_name not in model_provider_info["models"]: logger.warning(f"Model name {self.model_name} not in {self.model_provider}, using default model name") @@ -104,6 +107,7 @@ class Config(SimpleConfig): logger.warning(f"Model name {self.model_name} not in custom models, using default model name") self.model_name = self.custom_models[0]["custom_id"] + # 检查模型提供商的环境变量 conds = {} self.model_provider_status = {} for provider in self.model_names: @@ -111,11 +115,14 @@ class Config(SimpleConfig): conds_bool = [bool(os.getenv(_k)) for _k in conds[provider]] self.model_provider_status[provider] = all(conds_bool) + # 检查web_search的环境变量 + if self.enable_web_search and not os.getenv("TAVILY_API_KEY"): + logger.warning("TAVILY_API_KEY not set, web search will be disabled") + self.enable_web_search = False + self.valuable_model_provider = [k for k, v in self.model_provider_status.items() if v] assert len(self.valuable_model_provider) > 0, f"No model provider available, please check your `.env` file. API_KEY_LIST: {conds}" - - def load(self): """根据传入的文件覆盖掉默认配置""" logger.info(f"Loading config from {self.filename}") diff --git a/src/config/base.yaml b/src/config/base.yaml deleted file mode 100644 index 54c3aa4d..00000000 --- a/src/config/base.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# 基础配置 -stream: true -save_dir: saves - -# 功能开关 -enable_reranker: false -enable_knowledge_base: false -enable_knowledge_graph: false -enable_search_engine: false -enable_web_search: false - -# 模型配置 -model_provider: "deepseek" # 设置为 deepseek -model_name: "deepseek-chat" # 设置默认模型名称 -embed_model: "zhipu-embedding-3" -reranker: "bge-reranker-v2-m3" -model_local_paths: {} \ No newline at end of file diff --git a/src/core/database.py b/src/core/database.py index 10c616e0..3498dbb8 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -68,7 +68,9 @@ class DataBaseManager: assert self.config.enable_knowledge_base, "知识库未启用" knowledge_base_collections = self.knowledge_base.get_collection_names() if len(self.data["databases"]) != len(knowledge_base_collections): - logger.warning(f"Database number not match, {knowledge_base_collections}") + logger.warning( + f"Database number not match, {knowledge_base_collections}, " + f"self.data['databases']: {self.data['databases']}, ") for db in self.data["databases"]: db.update(self.knowledge_base.get_collection_info(db.metaname)) diff --git a/src/core/graphbase.py b/src/core/graphbase.py index b33e3fac..399ec448 100644 --- a/src/core/graphbase.py +++ b/src/core/graphbase.py @@ -27,6 +27,7 @@ class GraphDatabase: self.kgdb_name = kgdb_name assert embed_model, "embed_model=None" self.embed_model = embed_model + self.embed_model_name = None def start(self): uri = os.environ.get("NEO4J_URI", "bolt://localhost:7687") @@ -87,7 +88,8 @@ class GraphDatabase: "relationship_count": relationship_count, "triples_count": triples_count, "labels": labels, - "status": self.status + "status": self.status, + "embed_model_name": self.embed_model_name } with self.driver.session() as session: @@ -171,6 +173,7 @@ class GraphDatabase: def jsonl_file_add_entity(self, file_path, kgdb_name='neo4j'): self.status = "processing" kgdb_name = kgdb_name or 'neo4j' + self.embed_model_name = self.embed_model_name or self.config.embed_model self.use_database(kgdb_name) # 切换到指定数据库 def read_triples(file_path): diff --git a/src/core/knowledgebase.py b/src/core/knowledgebase.py index 65c24c64..99ecb830 100644 --- a/src/core/knowledgebase.py +++ b/src/core/knowledgebase.py @@ -1,6 +1,5 @@ import os -from src.models.embedding import EmbeddingModel from pymilvus import MilvusClient, MilvusException from src.utils import setup_logger, hashstr logger = setup_logger("KnowledgeBase") @@ -83,7 +82,7 @@ class KnowledgeBase: def search(self, query, collection_name, limit=3): - query_vectors = self.embed_model.encode_queries([query]) + query_vectors = self.embed_model.batch_encode([query]) return self.search_by_vector(query_vectors[0], collection_name, limit) def search_by_vector(self, vector, collection_name, limit=3): diff --git a/src/core/retriever.py b/src/core/retriever.py index 46b31c7e..1cfb862a 100644 --- a/src/core/retriever.py +++ b/src/core/retriever.py @@ -1,4 +1,4 @@ -from src.models.embedding import Reranker +from src.models.rerank_model import get_reranker from src.utils.logging_config import setup_logger logger = setup_logger("server-common") @@ -12,7 +12,11 @@ class Retriever: self.model = model if self.config.enable_reranker: - self.reranker = Reranker(config) + self.reranker = get_reranker(config) + + if self.config.enable_web_search: + from src.utils.web_search import WebSearcher + self.web_searcher = WebSearcher() def retrieval(self, query, history, meta): @@ -21,10 +25,12 @@ class Retriever: refs["entities"] = self.reco_entities(query, history, refs) refs["knowledge_base"] = self.query_knowledgebase(query, history, refs) refs["graph_base"] = self.query_graph(query, history, refs) + refs["web_search"] = self.query_web(query, history, refs) return refs def construct_query(self, query, refs, meta): + logger.debug(f"{refs=}") if not refs or len(refs) == 0: return query @@ -44,6 +50,12 @@ class Retriever: ) external_parts.extend(["图数据库信息:", db_text]) + # 解析网络搜索的结果 + web_res = refs.get("web_search", {}).get("results", []) + if web_res: + web_text = "\n".join(f"{r['title']}: {r['content']}" for r in web_res) + external_parts.extend(["网络搜索信息:", web_text]) + # 构造查询 from src.utils.prompts import knowbase_qa_template if external_parts and len(external_parts) > 0: @@ -60,8 +72,6 @@ class Retriever: raise NotImplementedError def query_graph(self, query, history, refs): - # res = model.predict("qiansdgsa, dasdh ashdsakjdk ak ").content - results = [] if refs["meta"].get("use_graph") and self.config.enable_knowledge_base: for entity in refs["entities"]: @@ -70,6 +80,7 @@ class Retriever: results.extend(result) return {"results": self.format_query_results(results)} + def query_knowledgebase(self, query, history, refs): """查询知识库""" @@ -100,15 +111,13 @@ class Retriever: for r in all_kb_res: r["file"] = kb.id2file(r["entity"]["file_id"]) - # use distance threshold to filter results - if meta.get("mode") == "search": - kb_res = all_kb_res - else: - kb_res = [r for r in all_kb_res if r["distance"] > distance_threshold] + kb_res = [r for r in all_kb_res if r["distance"] > distance_threshold] - if self.config.enable_reranker: - for r in kb_res: - r["rerank_score"] = self.reranker.compute_score([rw_query, r["entity"]["text"]], normalize=True)[0] + if self.config.enable_reranker and len(kb_res) > 0: + texts = [r["entity"]["text"] for r in kb_res] + rerank_scores = self.reranker.compute_score([rw_query, texts], normalize=True) + for i, r in enumerate(kb_res): + r["rerank_score"] = rerank_scores[i] kb_res.sort(key=lambda x: x["rerank_score"], reverse=True) kb_res = [_res for _res in kb_res if _res["rerank_score"] > rerank_threshold] @@ -116,12 +125,26 @@ class Retriever: return {"results": kb_res, "all_results": all_kb_res, "rw_query": rw_query} + def query_web(self, query, history, refs): + """查询网络""" + + if not (refs["meta"].get("use_web") and self.config.enable_web_search): + return {"results": [], "message": "Web search is disabled"} + + try: + search_results = self.web_searcher.search(query, max_results=5) + except Exception as e: + logger.error(f"Web search error: {str(e)}") + return {"results": [], "message": "Web search error"} + + return {"results": search_results} + def rewrite_query(self, query, history, refs): """重写查询""" if refs["meta"].get("mode") == "search": # 如果是搜索模式,就使用 meta 的配置,否则就使用全局的配置 rewrite_query_span = refs["meta"].get("use_rewrite_query", "off") else: - rewrite_query_span = refs["meta"]["config"].get("use_rewrite_query", "off") + rewrite_query_span = self.config.use_rewrite_query if rewrite_query_span == "off": rewritten_query = query diff --git a/src/core/startup.py b/src/core/startup.py index ee4f95dd..edab9216 100644 --- a/src/core/startup.py +++ b/src/core/startup.py @@ -10,33 +10,18 @@ logger = setup_logger("Startup") class Startup: def __init__(self): - self.config = Config("config/base.yaml") - self._check_environment() self.start() - def _check_environment(self): - """检查必要的环境变量""" - required_vars = { - "zhipu": ["ZHIPUAI_API_KEY"], - "openai": ["OPENAI_API_KEY"], - "deepseek": ["DEEPSEEK_API_KEY"], - } - - provider = self.config.model_provider - if provider in required_vars: - missing = [var for var in required_vars[provider] if not os.getenv(var)] - if missing: - logger.error(f"Missing required environment variables for {provider}: {missing}") - raise ValueError(f"Missing required environment variables: {missing}") - - if self.config.enable_web_search and not os.getenv("TAVILY_API_KEY"): - logger.warning("TAVILY_API_KEY not set, web search will be disabled") - def start(self): + self.config = Config() self.model = select_model(self.config) self.dbm = DataBaseManager(self.config) self.retriever = Retriever(self.config, self.dbm, self.model) + self.model_lite = select_model(self.config, + model_provider=self.config.model_provider_lite, + model_name=self.config.model_name_lite) + def restart(self): logger.info("Restarting...") self.start() diff --git a/src/models/__init__.py b/src/models/__init__.py index 4f7a3d39..c3d0a280 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -1,23 +1,51 @@ +import os + from src.utils.logging_config import logger -from src.models.chat_model import OpenModel, DeepSeek, Zhipu, Qianfan, DashScope, SiliconFlow -from src.models.embedding import get_embedding_model +from src.models.chat_model import OpenAIBase -def select_model(config): - """ - 根据配置选择模型 - """ - if config.model_provider == "deepseek": - return DeepSeek(config.model_name) - elif config.model_provider == "zhipu": - return Zhipu(config.model_name) - elif config.model_provider == "openai": - return OpenModel(config.model_name) - elif config.model_provider == "qianfan": - return Qianfan(config.model_name) - elif config.model_provider == "dashscope": - return DashScope(config.model_name) - elif config.model_provider == "siliconflow": - return SiliconFlow(config.model_name) +def select_model(config, model_provider=None, model_name=None): + + model_provider = model_provider or config.model_provider + model_info = config.model_names[model_provider] + model_name = model_name or config.model_name or model_info["default"] + + logger.info(f"Selecting model from `{model_provider}` with `{model_name}`") + + if model_provider in [ + "deepseek", + "ark", + "siliconflow", + "zhipu", + "lingyiwanwu", + ]: + return OpenAIBase( + api_key=os.getenv(model_info["env"][0]), + base_url=model_info["base_url"], + model_name=model_name, + ) + + elif model_provider == "qianfan": + from src.models.chat_model import Qianfan + return Qianfan(model_name) + + elif model_provider == "dashscope": + from src.models.chat_model import DashScope + return DashScope(model_name) + + elif model_provider == "openai": + from src.models.chat_model import OpenModel + return OpenModel(model_name) + + elif model_provider == "custom": + model_info = next((x for x in config.custom_models if x["custom_id"] == model_name), None) + if model_info is None: + raise ValueError(f"Model {model_name} not found in custom models") + + from src.models.chat_model import CustomModel + return CustomModel(model_info) + + elif model_provider is None: + raise ValueError("Model provider not specified, please modify `model_provider` in `src/config/base.yaml`") else: - raise ValueError(f"Unsupported model provider: {config.model_provider}") + raise ValueError(f"Model provider {model_provider} not supported") diff --git a/src/models/chat_model.py b/src/models/chat_model.py index bcf331b3..cfbcf4bb 100644 --- a/src/models/chat_model.py +++ b/src/models/chat_model.py @@ -1,7 +1,6 @@ import os from openai import OpenAI from src.utils.logging_config import setup_logger -from zhipuai import ZhipuAI logger = setup_logger(__name__) @@ -48,28 +47,6 @@ class OpenModel(OpenAIBase): super().__init__(api_key=api_key, base_url=base_url, model_name=model_name) -class DeepSeek(OpenAIBase): - def __init__(self, model_name=None): - model_name = model_name or "deepseek-chat" - api_key = os.getenv("DEEPSEEK_API_KEY", "your-default-api-key") - base_url = os.getenv("DEEPSEEK_API_BASE", "https://api.deepseek.com/v1") - super().__init__(api_key=api_key, base_url=base_url, model_name=model_name) - - -class Zhipu(OpenAIBase): - def __init__(self, model_name=None): - model_name = model_name or "glm-4-flash" - api_key = os.getenv("ZHIPUAI_API_KEY", "270ea71e9560c0ff406acbcdd48bfd97.e3XOMdWKuZb7Q1Sk") - base_url = "https://open.bigmodel.cn/api/paas/v4/" - super().__init__(api_key=api_key, base_url=base_url, model_name=model_name) - -class SiliconFlow(OpenAIBase): - def __init__(self, model_name=None): - model_name = model_name or "meta-llama/Meta-Llama-3.1-8B-Instruct" - api_key = os.getenv("SILICONFLOW_API_KEY") - base_url = "https://api.siliconflow.cn/v1" - super().__init__(api_key=api_key, base_url=base_url, model_name=model_name) - class CustomModel(OpenAIBase): def __init__(self, model_info): model_name = model_info["name"] @@ -166,14 +143,6 @@ class DashScope: return response.output.choices[0].message -class ChatModel: - def __init__(self, config): - if config.model_provider == "zhipu": - self.client = ZhipuAI(api_key=os.getenv("ZHIPUAI_API_KEY")) - elif config.model_provider == "openai": - self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) - - if __name__ == "__main__": model = SiliconFlow() for a in model.predict("你好", stream=True): diff --git a/src/models/embedding.py b/src/models/embedding.py index 604d1c1d..2bcd8906 100644 --- a/src/models/embedding.py +++ b/src/models/embedding.py @@ -1,105 +1,125 @@ import os -import uuid -from FlagEmbedding import FlagModel, FlagReranker +import json +import requests +from FlagEmbedding import FlagModel -from src.config import EMBED_MODEL_INFO, RERANKER_LIST +from src.config import EMBED_MODEL_INFO from src.utils.logging_config import setup_logger from src.utils import hashstr logger = setup_logger("EmbeddingModel") -GLOBAL_EMBED_STATE = {} - - -class EmbeddingModel(FlagModel): - def __init__(self, model_info, config, **kwargs): - self.info = model_info - model_name_or_path = config.model_local_paths.get(model_info["name"], model_info.get("default_path", None)) - logger.info(f"Loading embedding model {model_info['name']} from {model_name_or_path}") +class LocalEmbeddingModel(FlagModel): + def __init__(self, config, **kwargs): + info = EMBED_MODEL_INFO[config.embed_model] + model_name_or_path = config.model_local_paths.get(info["name"], info.get("default_path")) + logger.info(f"Loading embedding model {info['name']} from {model_name_or_path}") super().__init__(model_name_or_path, - query_instruction_for_retrieval=model_info.get("query_instruction", None), + query_instruction_for_retrieval=info.get("query_instruction", None), use_fp16=False, **kwargs) - logger.info(f"Embedding model {model_info['name']} loaded") + logger.info(f"Embedding model {info['name']} 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, default_path=RERANKER_LIST[config.reranker]) - logger.info(f"Loading Reranker model {config.reranker} from {model_name_or_path}") - - super().__init__(model_name_or_path, use_fp16=True, **kwargs) - logger.info(f"Reranker model {config.reranker} loaded") - from zhipuai import ZhipuAI -class ZhipuEmbedding: - def __init__(self, model_info, config) -> None: - self.config = config - self.model_info = model_info - self.client = ZhipuAI(api_key=os.getenv("ZHIPUAI_API_KEY")) - logger.info("Zhipu Embedding model loaded") - self.query_instruction_for_retrieval = "为这个句子生成表示以用于检索相关文章:" +class RemoteEmbeddingModel: + embed_state = {} - def predict(self, message): + def batch_encode(self, messages, batch_size=20): data = [] - batch_size = 20 - if len(message) > batch_size: - global GLOBAL_EMBED_STATE - task_id = hashstr(message) - logger.info(f"Creating new state for process {task_id}") - GLOBAL_EMBED_STATE[task_id] = { + if len(messages) > batch_size: + task_id = hashstr(messages) + self.embed_state[task_id] = { 'status': 'in-progress', - 'total': len(message), + 'total': len(messages), 'progress': 0 } - for i in range(0, len(message), batch_size): - if len(message) > batch_size: - logger.info(f"Encoding {i} to {i+batch_size} with {len(message)} messages") - GLOBAL_EMBED_STATE[task_id]['progress'] = i + for i in range(0, len(messages), batch_size): + group_msg = messages[i:i+batch_size] + logger.info(f"Encoding {i} to {i+batch_size} with {len(messages)} messages") + response = self.encode_queries(group_msg) + data.extend(response) - group_msg = message[i:i+batch_size] - response = self.client.embeddings.create( - model=self.model_info.get("default_path", None), - input=group_msg, - ) + if len(messages) > batch_size: + self.embed_state[task_id]['progress'] = len(messages) + self.embed_state[task_id]['status'] = 'completed' - data.extend([a.embedding for a in response.data]) + return data - if len(message) > batch_size: - GLOBAL_EMBED_STATE[task_id]['progress'] = len(message) - GLOBAL_EMBED_STATE[task_id]['status'] = 'completed' +class ZhipuEmbedding(RemoteEmbeddingModel): + def __init__(self, config) -> None: + self.config = config + self.model = EMBED_MODEL_INFO[config.embed_model]["name"] + self.client = ZhipuAI(api_key=os.getenv("ZHIPUAI_API_KEY")) + + def predict(self, message): + response = self.client.embeddings.create( + model=self.model, + input=message, + ) + data = [a.embedding for a in response.data] return data def encode(self, message): return self.predict(message) def encode_queries(self, queries): - # queries = [self.query_instruction_for_retrieval + query for query in queries] return self.predict(queries) +class SiliconFlowEmbedding(RemoteEmbeddingModel): + + def __init__(self, config) -> None: + self.url = "https://api.siliconflow.cn/v1/embeddings" + self.model = EMBED_MODEL_INFO[config.embed_model]["name"] + api_key = os.getenv("SILICONFLOW_API_KEY") + assert api_key, "SILICONFLOW_API_KEY is required" + self.headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + def encode(self, message): + payload = self.build_payload(message) + response = requests.request("POST", self.url, json=payload, headers=self.headers) + response = json.loads(response.text) + # logger.debug(f"SiliconFlow Embedding response: {response}") + assert response["data"], f"SiliconFlow Embedding failed: {response}" + data = [a["embedding"] for a in response["data"]] + return data + + def encode_queries(self, queries): + return self.encode(queries) + + def build_payload(self, message): + return { + "model": self.model, + "input": message, + } + def get_embedding_model(config): if not config.enable_knowledge_base: return None + provider, model_name = config.embed_model.split('/', 1) assert config.embed_model in EMBED_MODEL_INFO.keys(), f"Unsupported embed model: {config.embed_model}, only support {EMBED_MODEL_INFO.keys()}" + logger.debug(f"Loading embedding model {config.embed_model}") + if provider == "local": + model = LocalEmbeddingModel(config) - if config.embed_model in ["bge-large-zh-v1.5"]: - model = EmbeddingModel(EMBED_MODEL_INFO[config.embed_model], config) + if provider == "zhipu": + model = ZhipuEmbedding(config) - if config.embed_model in ["zhipu-embedding-2", "zhipu-embedding-3"]: - model = ZhipuEmbedding(EMBED_MODEL_INFO[config.embed_model], config) + if provider == "siliconflow": + model = SiliconFlowEmbedding(config) return model diff --git a/src/models/ollama_embedding.py b/src/models/ollama_embedding.py index 1b099be9..0819a46b 100644 --- a/src/models/ollama_embedding.py +++ b/src/models/ollama_embedding.py @@ -2,18 +2,20 @@ import os import requests import numpy as np from typing import List, Union, Dict + +from src.models.embedding import RemoteEmbeddingModel from src.utils.logging_config import setup_logger logger = setup_logger("OllamaEmbedding") -class OllamaEmbedding: +class OllamaEmbedding(RemoteEmbeddingModel): """ 使用 Ollama API 进行文本嵌入的类 """ def __init__(self, model_info: Dict, config) -> None: """ 初始化 Ollama Embedding 模型 - + Args: model_info: 模型信息字典 config: 配置对象 @@ -28,10 +30,10 @@ class OllamaEmbedding: def _get_embedding(self, text: str) -> List[float]: """ 获取单个文本的嵌入向量 - + Args: text: 输入文本 - + Returns: 嵌入向量 """ @@ -50,10 +52,10 @@ class OllamaEmbedding: def predict(self, messages: List[str]) -> List[List[float]]: """ 批量获取文本嵌入向量 - + Args: messages: 文本列表 - + Returns: 嵌入向量列表 """ @@ -63,23 +65,23 @@ class OllamaEmbedding: for i in range(0, len(messages), batch_size): batch = messages[i:i + batch_size] logger.info(f"Processing batch {i//batch_size + 1}, size: {len(batch)}") - + batch_embeddings = [] for text in batch: embedding = self._get_embedding(text) batch_embeddings.append(embedding) - + embeddings.extend(batch_embeddings) - + return embeddings def encode(self, messages: Union[str, List[str]]) -> List[List[float]]: """ 编码文本 - + Args: messages: 单个文本或文本列表 - + Returns: 嵌入向量列表 """ @@ -90,10 +92,10 @@ class OllamaEmbedding: def encode_queries(self, queries: List[str]) -> List[List[float]]: """ 编码查询文本 - + Args: queries: 查询文本列表 - + Returns: 查询文本的嵌入向量列表 """ @@ -107,7 +109,7 @@ class OllamaReranker: def __init__(self, config) -> None: """ 初始化 Ollama Reranker - + Args: config: 配置对象 """ @@ -119,16 +121,16 @@ class OllamaReranker: def compute_score(self, query: str, passage: str) -> float: """ 计算查询和文本段落之间的相关性分数 - + Args: query: 查询文本 passage: 段落文本 - + Returns: 相关性分数 """ prompt = f"Query: {query}\nPassage: {passage}\nRate the relevance of the passage to the query on a scale of 0 to 1:" - + try: response = requests.post( f"{self.base_url}/api/generate", @@ -139,7 +141,7 @@ class OllamaReranker: } ) response.raise_for_status() - + # 提取生成的数字作为分数 result = response.json()["response"].strip() try: @@ -148,7 +150,7 @@ class OllamaReranker: except ValueError: logger.warning(f"Could not parse score from response: {result}") return 0.0 - + except Exception as e: logger.error(f"Error computing rerank score: {str(e)}") return 0.0 @@ -156,12 +158,12 @@ class OllamaReranker: def rerank(self, query: str, passages: List[str], top_n: int = None) -> List[Dict]: """ 重新排序文本段落 - + Args: query: 查询文本 passages: 段落文本列表 top_n: 返回前 n 个结果 - + Returns: 排序后的结果列表,每个元素包含索引和分数 """ @@ -169,11 +171,11 @@ class OllamaReranker: for i, passage in enumerate(passages): score = self.compute_score(query, passage) scores.append({"index": i, "score": score}) - + # 按分数降序排序 sorted_results = sorted(scores, key=lambda x: x["score"], reverse=True) - + if top_n: sorted_results = sorted_results[:top_n] - - return sorted_results \ No newline at end of file + + return sorted_results \ No newline at end of file diff --git a/src/models/rerank_model.py b/src/models/rerank_model.py new file mode 100644 index 00000000..dbd24b0d --- /dev/null +++ b/src/models/rerank_model.py @@ -0,0 +1,70 @@ +import os +import json +import requests +import numpy as np +from FlagEmbedding import FlagReranker + +from src.config import RERANKER_LIST +from src.utils.logging_config import setup_logger + + +logger = setup_logger("RerankModel") + + +class LocalReranker(FlagReranker): + def __init__(self, config, **kwargs): + model_info = RERANKER_LIST[config.reranker] + model_name_or_path = config.model_local_paths.get(model_info["name"], model_info.get("default_path")) + logger.info(f"Loading Reranker model {config.reranker} from {model_name_or_path}") + + super().__init__(model_name_or_path, use_fp16=True, **kwargs) + logger.info(f"Reranker model {config.reranker} loaded") + + +def sigmoid(x): + return 1 / (1 + np.exp(-x)) + +class SilconFlowReranker(): + def __init__(self, config, **kwargs): + self.url = "https://api.siliconflow.cn/v1/rerank" + self.model = RERANKER_LIST[config.reranker]["name"] + + api_key = os.getenv("SILICONFLOW_API_KEY") + assert api_key, "SILICONFLOW_API_KEY is required" + self.headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + def compute_score(self, sentence_pairs, batch_size = 256, max_length = 512, normalize = False): + # TODO 还没实现 batch_size + query, sentences = sentence_pairs[0], sentence_pairs[1] + payload = self.build_payload(query, sentences, max_length) + response = requests.request("POST", self.url, json=payload, headers=self.headers) + response = json.loads(response.text) + logger.debug(f"SiliconFlow Reranker response: {response}") + + results = sorted(response["results"], key=lambda x: x["index"]) + all_scores = [result["relevance_score"] for result in results] + + if normalize: + all_scores = [sigmoid(score) for score in all_scores] + + return all_scores + + def build_payload(self, query, sentences, max_length = 512): + return { + "model": self.model, + "query": query, + "documents": sentences, + "max_chunks_per_doc": max_length, + } + +def get_reranker(config): + assert config.reranker in RERANKER_LIST.keys(), f"Unsupported Reranker: {config.reranker}, only support {RERANKER_LIST.keys()}" + provider, model_name = config.reranker.split('/', 1) + if provider == "local": + return LocalReranker(config) + elif provider == "siliconflow": + return SilconFlowReranker(config) + diff --git a/src/routers/chat_router.py b/src/routers/chat_router.py index ad136dde..f199c791 100644 --- a/src/routers/chat_router.py +++ b/src/routers/chat_router.py @@ -6,7 +6,6 @@ from concurrent.futures import ThreadPoolExecutor from src.core import HistoryManager from src.core.startup import startup from src.utils.logging_config import setup_logger -from src.utils.web_search import WebSearcher chat = APIRouter(prefix="/chat") logger = setup_logger("server-chat") @@ -14,7 +13,6 @@ logger = setup_logger("server-chat") executor = ThreadPoolExecutor() refs_pool = {} -web_searcher = WebSearcher() @chat.get("/") async def chat_get(): @@ -29,68 +27,68 @@ def chat_post( history_manager = HistoryManager(history) - def make_chunk(content, status, history): + def make_chunk(content=None, **kwargs): return json.dumps({ "response": content, - "history": history, "model_name": startup.config.model_name, - "status": status, "meta": meta, + **kwargs }, ensure_ascii=False).encode('utf-8') + b"\n" + def need_retrieve(meta): + return meta.get("use_web") or meta.get("use_graph") or meta.get("db_name") + def generate_response(): modified_query = query - - # 处理网页搜索 - if meta and meta.get("enable_web_search"): - chunk = make_chunk("正在进行网络搜索...", "searching", history=None) + refs = None + + # 处理知识库检索 + if meta and need_retrieve(meta): + chunk = make_chunk(status="searching") yield chunk try: - search_results = web_searcher.search(query) - if search_results: - search_context = web_searcher.format_search_results(search_results) - # 将搜索结果添加到查询中 - modified_query = f"""基于以下网络搜索结果回答问题: - - {search_context} - - 用户问题:{query} - - 请综合以上搜索结果,给出准确、客观的回答。如果搜索结果与问题相关性不大,请直接基于你的知识回答。 - """ - logger.info(f"Web search results added to query") - else: - logger.warning("No web search results found") + modified_query, refs = startup.retriever(modified_query, history_manager.messages, meta) except Exception as e: - logger.error(f"Web search error: {str(e)}") - chunk = make_chunk("网络搜索失败,将直接回答问题。", "loading", history=None) - yield chunk + logger.error(f"Retriever error: {e}") + yield make_chunk(message=f"Retriever error: {e}", status="error") + return - # 处理知识库检索 - if meta and meta.get("enable_retrieval"): - chunk = make_chunk("", "searching", history=None) - yield chunk - - modified_query, refs = startup.retriever(modified_query, history_manager.messages, meta) - refs_pool[cur_res_id] = refs + yield make_chunk(status="generating") messages = history_manager.get_history_with_msg(modified_query, max_rounds=meta.get('history_round')) history_manager.add_user(query) # 注意这里使用原始查询 - logger.debug(f"Final query: {modified_query}") + logger.debug(f"Web history: {history_manager.messages}") content = "" - for delta in startup.model.predict(messages, stream=True): - if not delta.content: - continue + reasoning_content = "" + try: + for delta in startup.model.predict(messages, stream=True): + if not delta.content and hasattr(delta, 'reasoning_content'): + reasoning_content += delta.reasoning_content or "" + chunk = make_chunk(reasoning_content=reasoning_content, status="reasoning") + yield chunk + continue - if hasattr(delta, 'is_full') and delta.is_full: - content = delta.content - else: - content += delta.content + # 文心一言 + if hasattr(delta, 'is_full') and delta.is_full: + content = delta.content + else: + content += delta.content or "" - chunk = make_chunk(content, "loading", history=history_manager.update_ai(content)) - yield chunk + chunk = make_chunk(content=content, status="loading") + yield chunk + + logger.debug(f"Final response: {content}") + logger.debug(f"Final reasoning response: {reasoning_content}") + yield make_chunk(content=content, + status="finished", + history=history_manager.update_ai(content), + refs=refs) + except Exception as e: + logger.error(f"Model error: {e}") + yield make_chunk(message=f"Model error: {e}", status="error") + return return StreamingResponse(generate_response(), media_type='application/json') @@ -105,6 +103,17 @@ async def call(query: str = Body(...), meta: dict = Body(None)): return {"response": response.content} +@chat.post("/call_lite") +async def call(query: str = Body(...), meta: dict = Body(None)): + async def predict_async(query): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(executor, startup.model_lite.predict, query) + + response = await predict_async(query) + logger.debug({"query": query, "response": response.content}) + + return {"response": response.content} + @chat.get("/refs") def get_refs(cur_res_id: str): global refs_pool diff --git a/src/static/config.yaml b/src/static/config.yaml new file mode 100644 index 00000000..e69de29b diff --git a/src/config/models.yaml b/src/static/models.yaml similarity index 57% rename from src/config/models.yaml rename to src/static/models.yaml index 90be011f..6ad405e7 100644 --- a/src/config/models.yaml +++ b/src/static/models.yaml @@ -14,6 +14,7 @@ MODEL_NAMES: name: DeepSeek url: https://platform.deepseek.com/api-docs/zh-cn/pricing default: deepseek-chat + base_url: https://api.deepseek.com/v1 env: - DEEPSEEK_API_KEY models: @@ -23,6 +24,7 @@ MODEL_NAMES: name: 智谱AI (Zhipu) url: https://open.bigmodel.cn/dev/api default: glm-4-flash + base_url: https://open.bigmodel.cn/api/paas/v4/ env: - ZHIPUAI_API_KEY models: @@ -33,6 +35,19 @@ MODEL_NAMES: - glm-4-long - glm-4-flashx - glm-4-flash + siliconflow: + name: SiliconFlow + url: https://cloud.siliconflow.cn/models + default: Qwen/Qwen2.5-7B-Instruct + base_url: https://api.siliconflow.cn/v1 + env: + - SILICONFLOW_API_KEY + models: + - meta-llama/Meta-Llama-3.1-8B-Instruct + - Qwen/Qwen2.5-7B-Instruct + - deepseek-ai/DeepSeek-R1 + - deepseek-ai/DeepSeek-V3 + - deepseek-ai/DeepSeek-R1-Distill-Qwen-7B qianfan: name: 百度千帆 (QianFan) url: https://open.bigmodel.cn/dev/api @@ -55,45 +70,51 @@ MODEL_NAMES: - DASHSCOPE_API_KEY models: - qwen-max-latest - - qwen-plus-latest - qwen-long-latest - - qwen-turbo-latest - qwen2.5-72b-instruct - qwen2.5-32b-instruct - - qwen2.5-14b-instruct - qwen2.5-7b-instruct - - qwen2.5-3b-instruct - qwen2.5-1.5b-instruct - qwen2.5-0.5b-instruct - siliconflow: - name: SiliconFlow - url: https://cloud.siliconflow.cn/models - default: meta-llama/Meta-Llama-3.1-8B-Instruct + ark: + name: 豆包(Ark) + url: https://console.volcengine.com/ark/region:ark+cn-beijing/model + default: doubao-1-5-pro-32k-250115 + base_url: https://ark.cn-beijing.volces.com/api/v3 env: - - SILICONFLOW_API_KEY + - ARK_API_KEY models: - - meta-llama/Meta-Llama-3.1-8B-Instruct - - meta-llama/Meta-Llama-3.1-70B-Instruct - - meta-llama/Meta-Llama-3.1-405B-Instruct + - doubao-1-5-pro-32k-250115 + - doubao-1-5-lite-32k-250115 + - deepseek-r1-250120 + lingyiwanwu: + name: 零一万物 + url: https://platform.lingyiwanwu.com/docs#%E6%A8%A1%E5%9E%8B%E4%B8%8E%E8%AE%A1%E8%B4%B9 + base_url: https://api.lingyiwanwu.com/v1 + default: yi-lightning + env: + - LINGYIWANWU_API_KEY + models: + - yi-lightning EMBED_MODEL_INFO: - bge-large-zh-v1.5: - name: bge-large-zh-v1.5 - default_path: BAAI/bge-large-zh-v1.5 - dimension: 1024 - query_instruction: "为这个句子生成表示以用于检索相关文章:" - bge-m3: - name: bge-m3 + local/BAAI/bge-m3: + name: BAAI/bge-m3 default_path: BAAI/bge-m3 dimension: 1024 - zhipu-embedding-2: - name: zhipu-embedding-2 - default_path: embedding-2 + zhipu/zhipu-embedding-2: + name: embedding-2 dimension: 1024 - zhipu-embedding-3: - name: zhipu-embedding-3 - default_path: embedding-3 + zhipu/zhipu-embedding-3: + name: embedding-3 dimension: 2048 + siliconflow/BAAI/bge-m3: + name: BAAI/bge-m3 + dimension: 1024 RERANKER_LIST: - bge-reranker-v2-m3: BAAI/bge-reranker-v2-m3 \ No newline at end of file + local/BAAI/bge-reranker-v2-m3: + name: BAAI/bge-reranker-v2-m3 + default_path: BAAI/bge-reranker-v2-m3 + siliconflow/BAAI/bge-reranker-v2-m3: + name: BAAI/bge-reranker-v2-m3 diff --git a/src/utils/web_search.py b/src/utils/web_search.py index 781e4553..e66ecf86 100644 --- a/src/utils/web_search.py +++ b/src/utils/web_search.py @@ -16,11 +16,11 @@ class WebSearcher: def search(self, query: str, max_results: int = 1) -> List[Dict]: """ 使用 Tavily 搜索相关内容 - + Args: query: 搜索查询 max_results: 最大返回结果数 - + Returns: 搜索结果列表 """ @@ -30,7 +30,7 @@ class WebSearcher: search_depth="basic", max_results=max_results ) - + # 提取需要的信息 formatted_results = [] for result in search_results['results'][:max_results]: @@ -40,9 +40,9 @@ class WebSearcher: 'url': result.get('url', ''), 'score': result.get('score', 0) }) - + return formatted_results - + except Exception as e: logger.error(f"Error during web search: {str(e)}") return [] @@ -50,20 +50,20 @@ class WebSearcher: def format_search_results(self, results: List[Dict]) -> str: """ 将搜索结果格式化为文本 - + Args: results: 搜索结果列表 - + Returns: 格式化后的文本 """ if not results: return "没有找到相关的网络搜索结果。" - + formatted_text = "以下是相关的网络搜索结果:\n\n" for i, result in enumerate(results, 1): formatted_text += f"{i}. {result['title']}\n" formatted_text += f" {result['content']}\n" formatted_text += f" 来源: {result['url']}\n\n" - - return formatted_text \ No newline at end of file + + return formatted_text \ No newline at end of file diff --git a/test/test_concurrency.py b/test/test_concurrency.py new file mode 100644 index 00000000..2c4c4b08 --- /dev/null +++ b/test/test_concurrency.py @@ -0,0 +1,121 @@ +import asyncio +import aiohttp +import time +import json +from typing import List + +async def make_request(session: aiohttp.ClientSession, request_id: int) -> dict: + """发送单个请求到API""" + url = "http://localhost:5000/chat/call" + payload = { + "query": "写一个冒泡排序", + "meta": {} + } + + start_time = time.time() + print(f"请求 {request_id} 开始时间: {time.strftime('%H:%M:%S', time.localtime(start_time))}") + try: + async with session.post(url, json=payload) as response: + result = await response.json() + end_time = time.time() + duration = end_time - start_time + print(f"请求 {request_id} 完成时间: {time.strftime('%H:%M:%S', time.localtime(end_time))} (耗时: {duration:.2f}秒)") + return { + "request_id": request_id, + "status": response.status, + "time": duration, + "start_time": start_time, + "end_time": end_time, + "success": True + } + except Exception as e: + end_time = time.time() + duration = end_time - start_time + print(f"请求 {request_id} 失败时间: {time.strftime('%H:%M:%S', time.localtime(end_time))} (耗时: {duration:.2f}秒)") + return { + "request_id": request_id, + "status": None, + "time": duration, + "start_time": start_time, + "end_time": end_time, + "success": False, + "error": str(e) + } + +async def run_concurrent_test(num_requests: int = 10) -> List[dict]: + """运行并发测试""" + async with aiohttp.ClientSession() as session: + tasks = [make_request(session, i) for i in range(num_requests)] + return await asyncio.gather(*tasks) + +def analyze_results(results: List[dict]) -> None: + """分析并打印测试结果""" + total_requests = len(results) + successful_requests = sum(1 for r in results if r["success"]) + failed_requests = total_requests - successful_requests + + response_times = [r["time"] for r in results if r["success"]] + if response_times: + avg_time = sum(response_times) / len(response_times) + max_time = max(response_times) + min_time = min(response_times) + else: + avg_time = max_time = min_time = 0 + + print(f"\n=== 并发测试结果 ===") + print(f"总请求数: {total_requests}") + print(f"成功请求: {successful_requests}") + print(f"失败请求: {failed_requests}") + print(f"平均响应时间: {avg_time:.2f} 秒") + print(f"最长响应时间: {max_time:.2f} 秒") + print(f"最短响应时间: {min_time:.2f} 秒") + + if failed_requests > 0: + print("\n失败的请求:") + for result in results: + if not result["success"]: + print(f"请求 ID {result['request_id']}: {result.get('error', '未知错误')}") + + # 添加请求时间线分析 + print("\n=== 请求时间线分析 ===") + sorted_results = sorted(results, key=lambda x: x["start_time"]) + test_start_time = sorted_results[0]["start_time"] + + print("\n时间线详情:") + print("请求ID 开始时间 结束时间 耗时(秒) 重叠请求数") + active_requests = [] + + for result in sorted_results: + # 计算当前时间点的活跃请求数 + start_time = result["start_time"] + end_time = result["end_time"] + + # 清理已完成的请求 + active_requests = [t for t in active_requests if t > start_time] + active_requests.append(end_time) + + print(f"{result['request_id']:^7} {time.strftime('%H:%M:%S', time.localtime(start_time))} " + f"{time.strftime('%H:%M:%S', time.localtime(end_time))} " + f"{result['time']:^8.2f} {len(active_requests):^6}") + + # 计算最大并发数 + max_concurrent = 0 + timeline = [] + for r in results: + timeline.append((r["start_time"], 1)) + timeline.append((r["end_time"], -1)) + + timeline.sort(key=lambda x: x[0]) + current_concurrent = 0 + for _, change in timeline: + current_concurrent += change + max_concurrent = max(max_concurrent, current_concurrent) + + print(f"\n最大并发请求数: {max_concurrent}") + +if __name__ == "__main__": + NUM_REQUESTS = 100 # 设置并发请求数 + + print(f"开始运行 {NUM_REQUESTS} 个并发请求的测试...") + results = asyncio.run(run_concurrent_test(NUM_REQUESTS)) + analyze_results(results) diff --git a/web/src/components/ChatComponent.vue b/web/src/components/ChatComponent.vue index d34d709e..8fc760e6 100644 --- a/web/src/components/ChatComponent.vue +++ b/web/src/components/ChatComponent.vue @@ -16,26 +16,6 @@
- - - - - - {{ meta.selectedKB === null ? '不使用' : opts.databases[meta.selectedKB]?.name }} - - - @@ -46,47 +26,9 @@
总结对话标题
-
- 启用检索
-
最大历史轮数
- - -
- 图数据库
-
-
- 搜索引擎(Bing)
-
-
- 网页搜索
-
-
@@ -110,6 +52,24 @@ class="message-box" :class="message.role" > +
+ + + +

{{ message.reasoning_content }}

+
+
+

{{ message.text }}

@@ -117,12 +77,13 @@
正在检索……
+
正在生成……
- 请求错误,请重试 + 请求错误,请重试。{{ message.message }}
- - - - +
+ +
+
-

请注意辨别内容的可靠性 模型供应商:{{ configStore.config?.model_provider }}: {{ configStore.config?.model_name }}

+

请注意辨别内容的可靠性 By {{ configStore.config?.model_provider }}: {{ configStore.config?.model_name }}

@@ -158,6 +162,8 @@ import { LoadingOutlined, BookOutlined, BookFilled, + CompassOutlined, + ArrowUpOutlined, CompassFilled, GoldenFilled, GoldOutlined, @@ -169,8 +175,8 @@ import { GlobalOutlined, FileTextOutlined, RobotOutlined, - EditOutlined, - PlusOutlined, + CaretRightOutlined, + DeploymentUnitOutlined, } from '@ant-design/icons-vue' import { onClickOutside } from '@vueuse/core' import { Marked } from 'marked'; @@ -208,15 +214,12 @@ const opts = reactive({ }) const meta = reactive(JSON.parse(localStorage.getItem('meta')) || { - enable_retrieval: false, use_graph: false, use_web: false, - enable_web_search: false, graph_name: "neo4j", - // use_rewrite_query: "off", selectedKB: null, stream: true, - summary_title: true, + summary_title: false, history_round: 5, db_name: null, }) @@ -235,15 +238,15 @@ const marked = new Marked( }) ); -const consoleMsg = (message) => console.log(message) +const consoleMsg = (msg) => console.log(msg) onClickOutside(panel, () => setTimeout(() => opts.showPanel = false, 30)) onClickOutside(modelCard, () => setTimeout(() => opts.showModelCard = false, 30)) -const renderMarkdown = (message) => { - if (message.status === 'loading') { - return marked.parse(message.text + '🟢') +const renderMarkdown = (msg) => { + if (msg.status === 'loading') { + return marked.parse(msg.text + '🟢') } else { - return marked.parse(message.text) + return marked.parse(msg.text) } } @@ -307,45 +310,73 @@ const generateRandomHash = (length) => { return hash; } -const appendUserMessage = (message) => { +const appendUserMessage = (msg) => { conv.value.messages.push({ id: generateRandomHash(16), role: 'sent', - text: message + text: msg }) scrollToBottom() } -const appendAiMessage = (message, refs=null) => { +const appendAiMessage = (text, refs=null) => { conv.value.messages.push({ id: generateRandomHash(16), role: 'received', - text: message, + text: text, + reasoning_content: '', refs, status: "init", meta: {}, + showThinking: "show" }) scrollToBottom() } const updateMessage = (info) => { - const message = conv.value.messages.find((message) => message.id === info.id); - if (message) { + const msg = conv.value.messages.find((msg) => msg.id === info.id); + if (msg) { try { + // 只有在 text 不为空时更新 if (info.text !== null && info.text !== undefined && info.text !== '') { - message.text = info.text; + msg.text = info.text; } + + if (info.reasoning_content !== null && info.reasoning_content !== undefined && info.reasoning_content !== '') { + msg.reasoning_content = info.reasoning_content; + } + + // 只有在 refs 不为空时更新 + if (info.refs !== null && info.refs !== undefined) { + msg.refs = info.refs; + } + + if (info.model_name !== null && info.model_name !== undefined && info.model_name !== '') { + msg.model_name = info.model_name; + } + + // 只有在 status 不为空时更新 if (info.status !== null && info.status !== undefined && info.status !== '') { - message.status = info.status; + msg.status = info.status; } + if (info.meta !== null && info.meta !== undefined) { - message.meta = info.meta; + msg.meta = info.meta; } + + if (info.message !== null && info.message !== undefined) { + msg.message = info.message; + } + + if (info.showThinking !== null && info.showThinking !== undefined) { + msg.showThinking = info.showThinking; + } + scrollToBottom(); } catch (error) { console.error('Error updating message:', error); - message.status = 'error'; - message.text = '消息更新失败'; + msg.status = 'error'; + msg.text = '消息更新失败'; } } else { console.error('Message not found:', info.id); @@ -354,9 +385,9 @@ const updateMessage = (info) => { const groupRefs = (id) => { - const message = conv.value.messages.find((message) => message.id === id) - if (message.refs && message.refs.knowledge_base.results.length > 0) { - message.groupedResults = message.refs.knowledge_base.results + const msg = conv.value.messages.find((msg) => msg.id === id) + if (msg.refs && msg.refs.knowledge_base.results.length > 0) { + msg.groupedResults = msg.refs.knowledge_base.results .filter(result => result.file && result.file.filename) .reduce((acc, result) => { const { filename } = result.file; @@ -370,11 +401,11 @@ const groupRefs = (id) => { scrollToBottom() } -const simpleCall = (message) => { +const simpleCall = (msg) => { return new Promise((resolve, reject) => { - fetch('/api/chat/call', { + fetch('/api/chat/call_lite', { method: 'POST', - body: JSON.stringify({ query: message, }), + body: JSON.stringify({ query: msg, }), headers: { 'Content-Type': 'application/json' } }) .then((response) => response.json()) @@ -393,21 +424,21 @@ const loadDatabases = () => { } // 新函数用于处理 fetch 请求 -const fetchChatResponse = (requestData) => { +const fetchChatResponse = (user_input, cur_res_id) => { fetch('/api/chat/', { method: 'POST', + body: JSON.stringify({ + query: user_input, + history: conv.value.history, + meta: meta, + cur_res_id: cur_res_id, + }), headers: { 'Content-Type': 'application/json' - }, - body: JSON.stringify(requestData) + } }) - .then(response => { - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - if (!response.body) { - throw new Error("ReadableStream not supported."); - } + .then((response) => { + if (!response.body) throw new Error("ReadableStream not supported."); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ''; @@ -415,25 +446,10 @@ const fetchChatResponse = (requestData) => { const readChunk = () => { return reader.read().then(({ done, value }) => { if (done) { - const message = conv.value.messages.find((message) => message.id === requestData.cur_res_id) - console.log(message) - if (message.meta.enable_retrieval) { - console.log("fetching refs") - fetchRefs(requestData.cur_res_id).then((data) => { - console.log(data) - updateMessage({ - id: requestData.cur_res_id, - refs: data, - status: "finished", - }); - groupRefs(requestData.cur_res_id); - }) - } else { - updateMessage({ - id: requestData.cur_res_id, - status: "finished", - }); - } + const msg = conv.value.messages.find((msg) => msg.id === cur_res_id) + console.log(msg) + groupRefs(cur_res_id); + updateMessage({showThinking: "no", id: cur_res_id}); isStreaming.value = false; if (conv.value.messages.length === 2) { renameTitle(); } return; @@ -449,11 +465,12 @@ const fetchChatResponse = (requestData) => { try { const data = JSON.parse(line); updateMessage({ - id: requestData.cur_res_id, + id: cur_res_id, text: data.response, - model_name: data.model_name, + reasoning_content: data.reasoning_content, status: data.status, meta: data.meta, + ...data, }); // console.log("Last message", conv.value.messages[conv.value.messages.length - 1].text) // console.log("Last message", conv.value.messages[conv.value.messages.length - 1].status) @@ -476,14 +493,12 @@ const fetchChatResponse = (requestData) => { readChunk(); }) .catch((error) => { - console.error('Error in fetchChatResponse:', error); + console.error(error); updateMessage({ - id: requestData.cur_res_id, + id: cur_res_id, status: "error", - text: `请求错误:${error.message}`, }); isStreaming.value = false; - message.error(`请求失败:${error.message}`); }); } @@ -508,36 +523,18 @@ const fetchRefs = (cur_res_id) => { const sendMessage = () => { const user_input = conv.value.inputText.trim(); const dbName = opts.databases.length > 0 ? opts.databases[meta.selectedKB]?.metaname : null; + if (isStreaming.value) { + message.error('请等待上一条消息处理完成'); + return + } if (user_input) { isStreaming.value = true; appendUserMessage(user_input); appendAiMessage("", null); const cur_res_id = conv.value.messages[conv.value.messages.length - 1].id; conv.value.inputText = ''; - - // 准备发送的数据 - const requestData = { - query: user_input, - history: conv.value.history, - cur_res_id: cur_res_id, - meta: { - enable_retrieval: meta.enable_retrieval, - use_graph: meta.use_graph, - use_web: meta.use_web, - enable_web_search: meta.enable_web_search, - graph_name: meta.graph_name, - rewriteQuery: meta.rewriteQuery, - selectedKB: meta.selectedKB, - stream: meta.stream, - summary_title: meta.summary_title, - history_round: meta.history_round, - db_name: dbName, - } - }; - - console.log('Sending request with data:', requestData); // 添加日志 - - fetchChatResponse(requestData); + meta.db_name = dbName; + fetchChatResponse(user_input, cur_res_id) } else { console.log('请输入消息'); } @@ -545,7 +542,7 @@ const sendMessage = () => { const retryMessage = (id) => { // 找到 id 对应的 message,然后删除包含 message 在内以及后面所有的 message - const index = conv.value.messages.findIndex(message => message.id === id); + const index = conv.value.messages.findIndex(msg => msg.id === id); const pastMessage = conv.value.messages[index-1] console.log("retryMessage", id, pastMessage) conv.value.inputText = pastMessage.text @@ -556,8 +553,8 @@ const retryMessage = (id) => { sendMessage(); } -const autoSend = (message) => { - conv.value.inputText = message +const autoSend = (msg) => { + conv.value.inputText = msg sendMessage() } @@ -670,7 +667,6 @@ watch( margin-right: 8px; font-size: 16px; } - .ant-switch { &.ant-switch-checked { background-color: var(--main-500); @@ -755,18 +751,25 @@ watch( /* animation: slideInUp 0.1s ease-in; */ .err-msg { - color: #FF6B6B; - border: 1px solid #FF6B6B; - padding: 0.2rem 1rem; + color: #eb8080; + border: 1px solid #eb8080; + padding: 0.5rem 1rem; border-radius: 8px; - text-align: center; - background: #FFF0F0; + text-align: left; + background: #FFF5F5; margin-bottom: 10px; cursor: pointer; } .searching-msg { color: var(--gray-500); + animation: colorPulse 2s infinite; + } + + @keyframes colorPulse { + 0% { color: var(--gray-700); } + 50% { color: var(--gray-300); } + 100% { color: var(--gray-700); } } } @@ -783,6 +786,7 @@ watch( text-align: left; word-wrap: break-word; margin: 0; + max-width: 100%; padding-bottom: 0; padding-top: 16px; padding-left: 0; @@ -812,21 +816,56 @@ watch( .input-box { display: flex; + flex-direction: column; width: 100%; height: auto; max-width: 900px; margin: 0 auto; - align-items: flex-end; padding: 0.25rem 0.5rem; - // box-shadow: rgba(42, 60, 79, 0.1) 0px 6px 10px 0px; - border: 2px solid #E5E5E5; + border: 2px solid var(--gray-200); border-radius: 1rem; - background: #fcfdfd; - transition: background 0.3s, box-shadow 0.3s; + background: var(--gray-50); + transition: background, border 0.3s, box-shadow 0.3s; &:focus-within { border: 2px solid var(--main-500); background: white; - // box-shadow: rgb(42 60 79 / 5%) 0px 4px 10px 0px; + } + + .input-options { + display: flex; + padding: 4px 8px; + + .options__left, + .options__right { + display: flex; + align-items: center; + gap: 8px; + } + + .options__left { + flex: 1; + + .opt-item { + border-radius: 12px; + border: 1px solid var(--gray-300); + padding: 4px 8px; + cursor: pointer; + font-size: 12px; + color: var(--gray-700); + + &.active { + color: var(--main-600); + border: 1px solid var(--main-500); + background-color: var(--main-10); + } + } + } + } + + .input-area { + display: flex; + align-items: flex-end; + gap: 8px; } textarea.user-input { @@ -836,7 +875,7 @@ watch( background-color: transparent; border: none; font-size: 1.2rem; - margin: 0 0.6rem; + margin: 0 0; color: #111111; font-size: 16px; font-variation-settings: 'wght' 400, 'opsz' 10.5; @@ -854,21 +893,23 @@ watch( } button.ant-btn-icon-only { - font-size: 1.25rem; + height: 32px; + width: 32px; cursor: pointer; - background-color: transparent; + background-color: var(--main-color); + border-radius: 50%; border: none; transition: color 0.3s; box-shadow: none; - color: var(--main-700);; + color: white; padding: 0; &:hover { - color: var(--gray-1000); + background-color: var(--main-800); } &:disabled { - color: #ccc; + background-color: var(--gray-400); cursor: not-allowed; } } @@ -1009,7 +1050,6 @@ watch( display: flex; align-items: center; gap: 8px; - .search-switch { margin-right: 8px; } @@ -1029,6 +1069,7 @@ watch( } .message-md { + max-width: 100%; h1, h2, h3, h4, h5, h6 { font-size: 1rem; } diff --git a/web/src/components/GraphContainer.vue b/web/src/components/GraphContainer.vue index 30324c20..41794cdb 100644 --- a/web/src/components/GraphContainer.vue +++ b/web/src/components/GraphContainer.vue @@ -1,95 +1,95 @@ +
+ - - - \ No newline at end of file +}); + +const container = ref(null); +let graphInstance = null; + +const initGraph = () => { + graphInstance = new Graph({ + container: container.value, + width: container.value.offsetWidth, + height: container.value.offsetHeight, + autoFit: true, + autoResize: true, + layout: { + type: 'd3-force', + preventOverlap: true, + kr: 20, + collide: { + strength: 1.0, + }, + }, + node: { + type: 'circle', + style: { + labelText: (d) => d.data.label, + size: 70, + }, + palette: { + field: 'label', + color: 'tableau', + }, + }, + edge: { + type: 'line', + style: { + labelText: (d) => d.data.label, + labelBackground: '#fff', + endArrow: true, + }, + }, + behaviors: ['drag-element', 'zoom-canvas', 'drag-canvas'], + }); +}; + +const renderGraph = () => { + if (!graphInstance) { + initGraph(); + } + + const formattedData = { + nodes: props.graphData.nodes.map(node => ({ + id: node.id, + data: { label: node.name } + })), + edges: props.graphData.edges.map(edge => ({ + source: edge.source_id, + target: edge.target_id, + data: { label: edge.type } + })) + }; + + graphInstance.setData(formattedData); + graphInstance.render(); +}; + +onMounted(() => { + renderGraph(); + window.addEventListener('resize', renderGraph); +}); + +watch(() => props.graphData, renderGraph, { deep: true }); + + + \ No newline at end of file diff --git a/web/src/components/RefsComponent.vue b/web/src/components/RefsComponent.vue index 342994f7..9a081fa8 100644 --- a/web/src/components/RefsComponent.vue +++ b/web/src/components/RefsComponent.vue @@ -1,16 +1,23 @@ @@ -73,6 +109,8 @@ import { CopyOutlined, LikeOutlined, DislikeOutlined, + DeploymentUnitOutlined, + BulbOutlined, FileOutlined, ClockCircleOutlined } from '@ant-design/icons-vue' @@ -143,6 +181,20 @@ const closeSubGraph = () => { subGraphVisible.value = false } +// 添加网页搜索结果相关变量 +const webResultVisible = ref(false) +const webResults = ref(null) + +// 添加显示网页搜索结果的方法 +const showWebResult = (msg) => { + if (msg.refs?.web_search) { + webResults.value = msg.refs?.web_search.results + webResultVisible.value = true + } else { + console.error('无法获取网页搜索结果') + } +} + const hasSubGraphData = (msg) => { return msg.refs && msg.refs.graph_base && @@ -165,15 +217,15 @@ const getPercent = (value) => { display: flex; margin-bottom: 20px; color: var(--gray-500); - font-size: 14px; + font-size: 13px; gap: 10px; .item { background: var(--gray-100); - color: var(--gray-800); + color: var(--gray-700); padding: 2px 8px; border-radius: 8px; - font-size: 14px; + font-size: 13px; user-select: none; &.btn { @@ -275,4 +327,76 @@ const getPercent = (value) => { margin-bottom: 12px; } } + +.web-result-detail { + .results-list { + .result-item { + border-bottom: 1px solid #f0f0f0; + padding: 16px 0; + + &:last-child { + border-bottom: none; + } + } + + .result-meta { + margin-bottom: 12px; + + .score-info { + display: flex; + flex-wrap: wrap; + gap: 2rem; + margin-bottom: 8px; + + span { + display: flex; + align-items: center; + + strong { + margin-right: 8px; + white-space: nowrap; + color: #666; + } + + .ant-progress { + width: 170px; + margin-bottom: 0; + margin-inline: 10px; + + .ant-progress-bg { + background-color: #666; + } + } + } + } + + .result-url { + font-size: 12px; + color: #1677FF; + margin-bottom: 8px; + word-break: break-all; + } + } + + .result-content { + .result-title { + font-size: 16px; + font-weight: bold; + margin-bottom: 8px; + color: #333; + } + + .result-text { + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + background-color: #f9f9f9; + padding: 12px; + border-radius: 4px; + border: 1px solid #e8e8e8; + } + } + } +} \ No newline at end of file diff --git a/web/src/components/TableConfigComponent.vue b/web/src/components/TableConfigComponent.vue index 7b372d4c..a08f4bd2 100644 --- a/web/src/components/TableConfigComponent.vue +++ b/web/src/components/TableConfigComponent.vue @@ -43,7 +43,7 @@ @@ -91,12 +91,14 @@ props.config && Object.entries(props.config).forEach(([key, value]) => { // 控制模态框显示 const addConfigModalVisible = ref(false); +const isAdding = ref(false); // 添加新的ref变量 // 新增配置项数据 const newConfig = ref({ key: '', value: '' }); // 添加配置 const addConfig = () => { + isAdding.value = true; // 设置添加状态 addConfigModalVisible.value = true; }; @@ -113,6 +115,7 @@ const confirmAddConfig = () => { configList.push({ key: newConfig.value.key, value: newConfig.value.value }); addConfigModalVisible.value = false; newConfig.value = { key: '', value: '' }; + isAdding.value = false; // 重置添加状态 }; // 删除配置 @@ -137,6 +140,13 @@ const configObject = computed(() => { watch(configObject, (newValue) => { emit('update:config', newValue); }, { deep: true }); + +// 监听模态框关闭 +watch(addConfigModalVisible, (newValue) => { + if (!newValue) { + isAdding.value = false; // 当模态框关闭时重置添加状态 + } +}); + \ No newline at end of file diff --git a/web/src/router/index.js b/web/src/router/index.js index 613c1406..35574dd3 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -7,7 +7,7 @@ const router = createRouter({ routes: [ { path: '/', - name: 'home', + name: 'main', component: BlankLayout, children: [ { path: '', @@ -24,7 +24,7 @@ const router = createRouter({ children: [ { path: '', - name: 'Chat', + name: 'ChatComp', component: () => import('../views/ChatView.vue'), meta: { keepAlive: true } } @@ -37,7 +37,7 @@ const router = createRouter({ children: [ { path: '', - name: 'Graph', + name: 'GraphComp', component: () => import('../views/GraphView.vue'), meta: { keepAlive: false } } @@ -50,13 +50,13 @@ const router = createRouter({ children: [ { path: '', - name: 'Database', + name: 'DatabaseComp', component: () => import('../views/DataBaseView.vue'), meta: { keepAlive: true } }, { path: ':database_id', - name: 'databaseInfo', + name: 'DatabaseInfoComp', component: () => import('../views/DataBaseInfoView.vue'), meta: { keepAlive: false } } @@ -69,7 +69,7 @@ const router = createRouter({ children: [ { path: '', - name: 'Setting', + name: 'SettingComp', component: () => import('../views/SettingView.vue'), meta: { keepAlive: true } } @@ -82,7 +82,7 @@ const router = createRouter({ children: [ { path: '', - name: 'ToolsView', + name: 'ToolsComp', component: () => import('../views/ToolsView.vue'), meta: { keepAlive: true } }, diff --git a/web/src/views/ChatView.vue b/web/src/views/ChatView.vue index fc982936..55b09876 100644 --- a/web/src/views/ChatView.vue +++ b/web/src/views/ChatView.vue @@ -41,9 +41,16 @@ const convs = reactive(JSON.parse(localStorage.getItem('chat-convs')) || [ ]) const state = reactive({ - isSidebarOpen: true, + isSidebarOpen: JSON.parse(localStorage.getItem('chat-sidebar-open') || 'true'), }) +// Watch isSidebarOpen and save to localStorage +watch( + () => state.isSidebarOpen, + (newValue) => { + localStorage.setItem('chat-sidebar-open', JSON.stringify(newValue)) + } +) const curConvId = ref(0) const generateRandomHash = (length) => { diff --git a/web/src/views/GraphView.vue b/web/src/views/GraphView.vue index 861acf99..40b50e95 100644 --- a/web/src/views/GraphView.vue +++ b/web/src/views/GraphView.vue @@ -3,7 +3,7 @@ @@ -11,7 +11,7 @@
diff --git a/web/src/views/SettingView.vue b/web/src/views/SettingView.vue index a314595a..2942ab34 100644 --- a/web/src/views/SettingView.vue +++ b/web/src/views/SettingView.vue @@ -2,7 +2,7 @@