From 0bacf2e0a4d4ceb3491af4e9c21e037285a5e3bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Wed, 5 Aug 2026 10:28:44 +0800 Subject: [PATCH] 1 --- .dockerignore | 10 + .gitignore | 12 + Dockerfile | 31 + README.md | 119 ++ common/auth.go | 47 + common/auth_middleware.go | 81 + common/base_dao.go | 55 + common/cache.go | 22 + common/docx_parser.go | 87 + common/html_parser.go | 34 + common/http.go | 49 + common/parser.go | 53 + common/parser_test.go | 97 ++ common/pdf_parser.go | 35 + common/text_parser.go | 20 + common/tokenizer.go | 47 + common/util.go | 22 + config.yml | 30 + docker-compose.yml | 13 + docs/实现方案.md | 881 ++++++++++ go.mod | 81 + go.sum | 332 ++++ kb/consts/consts.go | 17 + kb/consts/content_type.go | 14 + kb/consts/status.go | 30 + kb/consts/table_name.go | 23 + kb/controller/chunk_controller.go | 32 + kb/controller/conversation_controller.go | 40 + kb/controller/dataset_controller.go | 42 + kb/controller/document_controller.go | 62 + kb/controller/kg_entity_controller.go | 25 + kb/controller/kg_relation_controller.go | 25 + kb/controller/message_controller.go | 78 + kb/controller/model_config_controller.go | 49 + kb/controller/parse_task_controller.go | 32 + kb/controller/system_config_controller.go | 53 + kb/dao/chunk_dao.go | 244 +++ kb/dao/conversation_dao.go | 94 ++ kb/dao/dataset_dao.go | 95 ++ kb/dao/document_dao.go | 98 ++ kb/dao/kg_entity_dao.go | 117 ++ kb/dao/kg_relation_dao.go | 137 ++ kb/dao/message_dao.go | 54 + kb/dao/model_config_dao.go | 110 ++ kb/dao/parse_task_dao.go | 110 ++ kb/dao/system_config_dao.go | 57 + kb/model/domain/retrieval.go | 66 + kb/model/dto/chunk_dto.go | 29 + kb/model/dto/conversation_dto.go | 33 + kb/model/dto/dataset_dto.go | 34 + kb/model/dto/document_dto.go | 46 + kb/model/dto/kg_entity_dto.go | 21 + kb/model/dto/kg_relation_dto.go | 21 + kb/model/dto/message_dto.go | 26 + kb/model/dto/model_config_dto.go | 49 + kb/model/dto/parse_task_dto.go | 27 + kb/model/dto/system_config_dto.go | 45 + kb/model/entity/chunk.go | 13 + kb/model/entity/conversation.go | 11 + kb/model/entity/dataset.go | 13 + kb/model/entity/document.go | 17 + kb/model/entity/kg_entity.go | 13 + kb/model/entity/kg_relation.go | 13 + kb/model/entity/message.go | 12 + kb/model/entity/model_config.go | 16 + kb/model/entity/parse_task.go | 14 + kb/model/entity/system_config.go | 10 + kb/service/chat_service.go | 476 ++++++ kb/service/chunk_service.go | 182 ++ kb/service/conversation_service.go | 49 + kb/service/dataset_service.go | 46 + kb/service/document_service.go | 161 ++ kb/service/kg_entity_service.go | 117 ++ kb/service/kg_relation_service.go | 90 + kb/service/message_service.go | 83 + kb/service/model_config_service.go | 87 + kb/service/parse_task_service.go | 130 ++ kb/service/system_config_service.go | 100 ++ main.go | 95 ++ ui-src/.gitignore | 3 + ui-src/index.html | 12 + ui-src/package-lock.json | 1862 +++++++++++++++++++++ ui-src/package.json | 23 + ui-src/src/App.vue | 3 + ui-src/src/api/auth.js | 21 + ui-src/src/api/chat.js | 73 + ui-src/src/api/chunk.js | 9 + ui-src/src/api/dataset.js | 13 + ui-src/src/api/document.js | 21 + ui-src/src/api/kg.js | 9 + ui-src/src/api/model_config.js | 17 + ui-src/src/api/request.js | 43 + ui-src/src/main.js | 13 + ui-src/src/router/index.js | 69 + ui-src/src/stores/auth.js | 23 + ui-src/src/views/Chat.vue | 302 ++++ ui-src/src/views/DatasetDetail.vue | 264 +++ ui-src/src/views/DatasetList.vue | 121 ++ ui-src/src/views/KgGraph.vue | 113 ++ ui-src/src/views/Layout.vue | 89 + ui-src/src/views/Login.vue | 84 + ui-src/src/views/Settings.vue | 255 +++ ui-src/vite.config.js | 32 + 103 files changed, 9415 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 common/auth.go create mode 100644 common/auth_middleware.go create mode 100644 common/base_dao.go create mode 100644 common/cache.go create mode 100644 common/docx_parser.go create mode 100644 common/html_parser.go create mode 100644 common/http.go create mode 100644 common/parser.go create mode 100644 common/parser_test.go create mode 100644 common/pdf_parser.go create mode 100644 common/text_parser.go create mode 100644 common/tokenizer.go create mode 100644 common/util.go create mode 100644 config.yml create mode 100644 docker-compose.yml create mode 100644 docs/实现方案.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 kb/consts/consts.go create mode 100644 kb/consts/content_type.go create mode 100644 kb/consts/status.go create mode 100644 kb/consts/table_name.go create mode 100644 kb/controller/chunk_controller.go create mode 100644 kb/controller/conversation_controller.go create mode 100644 kb/controller/dataset_controller.go create mode 100644 kb/controller/document_controller.go create mode 100644 kb/controller/kg_entity_controller.go create mode 100644 kb/controller/kg_relation_controller.go create mode 100644 kb/controller/message_controller.go create mode 100644 kb/controller/model_config_controller.go create mode 100644 kb/controller/parse_task_controller.go create mode 100644 kb/controller/system_config_controller.go create mode 100644 kb/dao/chunk_dao.go create mode 100644 kb/dao/conversation_dao.go create mode 100644 kb/dao/dataset_dao.go create mode 100644 kb/dao/document_dao.go create mode 100644 kb/dao/kg_entity_dao.go create mode 100644 kb/dao/kg_relation_dao.go create mode 100644 kb/dao/message_dao.go create mode 100644 kb/dao/model_config_dao.go create mode 100644 kb/dao/parse_task_dao.go create mode 100644 kb/dao/system_config_dao.go create mode 100644 kb/model/domain/retrieval.go create mode 100644 kb/model/dto/chunk_dto.go create mode 100644 kb/model/dto/conversation_dto.go create mode 100644 kb/model/dto/dataset_dto.go create mode 100644 kb/model/dto/document_dto.go create mode 100644 kb/model/dto/kg_entity_dto.go create mode 100644 kb/model/dto/kg_relation_dto.go create mode 100644 kb/model/dto/message_dto.go create mode 100644 kb/model/dto/model_config_dto.go create mode 100644 kb/model/dto/parse_task_dto.go create mode 100644 kb/model/dto/system_config_dto.go create mode 100644 kb/model/entity/chunk.go create mode 100644 kb/model/entity/conversation.go create mode 100644 kb/model/entity/dataset.go create mode 100644 kb/model/entity/document.go create mode 100644 kb/model/entity/kg_entity.go create mode 100644 kb/model/entity/kg_relation.go create mode 100644 kb/model/entity/message.go create mode 100644 kb/model/entity/model_config.go create mode 100644 kb/model/entity/parse_task.go create mode 100644 kb/model/entity/system_config.go create mode 100644 kb/service/chat_service.go create mode 100644 kb/service/chunk_service.go create mode 100644 kb/service/conversation_service.go create mode 100644 kb/service/dataset_service.go create mode 100644 kb/service/document_service.go create mode 100644 kb/service/kg_entity_service.go create mode 100644 kb/service/kg_relation_service.go create mode 100644 kb/service/message_service.go create mode 100644 kb/service/model_config_service.go create mode 100644 kb/service/parse_task_service.go create mode 100644 kb/service/system_config_service.go create mode 100644 main.go create mode 100644 ui-src/.gitignore create mode 100644 ui-src/index.html create mode 100644 ui-src/package-lock.json create mode 100644 ui-src/package.json create mode 100644 ui-src/src/App.vue create mode 100644 ui-src/src/api/auth.js create mode 100644 ui-src/src/api/chat.js create mode 100644 ui-src/src/api/chunk.js create mode 100644 ui-src/src/api/dataset.js create mode 100644 ui-src/src/api/document.js create mode 100644 ui-src/src/api/kg.js create mode 100644 ui-src/src/api/model_config.js create mode 100644 ui-src/src/api/request.js create mode 100644 ui-src/src/main.js create mode 100644 ui-src/src/router/index.js create mode 100644 ui-src/src/stores/auth.js create mode 100644 ui-src/src/views/Chat.vue create mode 100644 ui-src/src/views/DatasetDetail.vue create mode 100644 ui-src/src/views/DatasetList.vue create mode 100644 ui-src/src/views/KgGraph.vue create mode 100644 ui-src/src/views/Layout.vue create mode 100644 ui-src/src/views/Login.vue create mode 100644 ui-src/src/views/Settings.vue create mode 100644 ui-src/vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8897163 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.idea +data +workspace +docs +ui-src/node_modules +ui-src/dist +Dockerfile +docker-compose.yml +README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f98bc2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# 运行时数据与本地环境 +data/ +workspace/ +.idea/ +.DS_Store + +# 前端 +ui-src/node_modules/ +ui-src/dist/ + +# 构建产物 +rag-local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b343e2f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# ==================== 阶段 1:构建前端 SPA ==================== +FROM node:20-alpine AS ui +WORKDIR /ui +COPY ui-src/package.json ui-src/package-lock.json ./ +RUN npm ci +COPY ui-src/ ./ +RUN npm run build + +# ==================== 阶段 2:构建后端二进制(modernc sqlite 纯 Go,无 CGO) ==================== +FROM golang:1.26 AS build +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o rag-local . + +# ==================== 阶段 3:精简运行镜像 ==================== +FROM alpine:3.20 +WORKDIR /app +RUN apk add --no-cache tzdata && \ + adduser -D -u 1000 app && \ + mkdir -p /app/data /app/workspace && \ + chown -R app:app /app +ENV TZ=Asia/Shanghai +COPY --from=build /app/rag-local /app/rag-local +COPY --from=build /app/config.yml /app/config.yml +COPY --from=ui /ui/dist /app/ui-src/dist +USER app +EXPOSE 8080 +VOLUME ["/app/data", "/app/workspace"] +ENTRYPOINT ["/app/rag-local"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e264ae --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# rag-local 本地知识库 + +纯本地的 RAG(检索增强生成)知识库系统,基于 SQLite 全栈文件型存储,无外部数据库依赖。 + +## 功能 + +- **文档流水线**:上传 txt / md / pdf / docx / html → 自动解析 → 标题感知分块 → 向量化 + 全文索引 +- **混合检索**:sqlite-vec 向量 KNN + FTS5 全文 BM25,RRF 融合排序,中文 gse 分词 +- **RAG 问答**:SSE 流式对话,检索引用(含来源与得分)随回答展示,会话历史持久化 +- **知识图谱**:解析时 LLM 抽取实体与关系,问答时实体链接 + 一跳邻居注入提示词,图谱页可视化实体/关系 +- **单用户门禁**:启动生成访问令牌,登录后 JWT 鉴权 + +## 技术栈 + +| 层 | 技术 | +|---|---| +| 后端 | Go + GoFrame v2 + Eino(LLM 编排) | +| 存储 | SQLite(modernc 纯 Go 驱动 + sqlite-vec 扩展) | +| 全文 | SQLite FTS5 + gse 中文分词 | +| 前端 | Vue 3 + Element Plus + Vite(前后端不分离,单端口) | +| 部署 | Docker Compose 一键启动 | + +三个 SQLite 文件各司其职: + +| 库 | 文件 | 内容 | +|---|---|---| +| business | `data/business.db` | 数据集/文档/分块/向量/全文/解析任务/知识图谱 | +| system | `data/system.db` | 系统配置(令牌、默认模型)与模型配置 | +| chat | `data/chat.db` | 会话与消息 | + +## 快速开始(Docker) + +```bash +docker compose up -d --build +``` + +- 浏览器打开 http://localhost:8080 +- 启动日志中查看访问令牌(`docker compose logs rag-local` 搜索"访问令牌") +- 登录后按以下流程使用: + 1. **设置** 页添加对话模型与向量模型(OpenAI 兼容接口,如 Ollama / vLLM / one-api),并点击"测试"验证连通 + 2. **数据集** 页新建数据集,绑定向量模型(不绑定则仅全文检索) + 3. 进入数据集上传文档,等待解析完成 + 4. **问答** 页选择知识库开始提问,回答可展开查看引用来源 + 5. **知识图谱** 页查看解析时抽取的实体与关系 + +数据保存在 `./data` 与 `./workspace`,删除容器不丢失。 + +## 本地开发 + +```bash +# 后端(Go 1.26+) +go run . # 监听 :8080,启动日志打印访问令牌 + +# 前端(开发热更新) +cd ui-src +npm install +npm run dev # Vite 开发服务器,API 代理见 vite.config.js +``` + +生产构建时前端产物在 `ui-src/dist`,由 Go 直接托管。 + +## 模型配置 + +任意 OpenAI 兼容接口均可使用: + +- **对话模型**(`/v1/chat/completions`,支持 SSE 流式):用于问答与知识图谱实体抽取 +- **向量模型**(`/v1/embeddings`):用于分块向量化与语义检索;维度须与 `config.yml` 中 `vector.dim` 一致(默认 1024) + +示例(Ollama): + +```bash +# 拉取模型 +ollama pull qwen2.5:7b +ollama pull nomic-embed-text + +# 设置页配置: +# 对话模型:endpoint http://localhost:11434/v1,模型名 qwen2.5:7b +# 向量模型:endpoint http://localhost:11434/v1,模型名 nomic-embed-text,维度 768 +``` + +> 注意:向量维度变更需清空 `data/business.db` 重建(vec0 表建表维度固定)。 + +## 配置说明(config.yml) + +| 配置 | 说明 | +|---|---| +| `server.address` | 监听地址,默认 `:8080` | +| `server.clientMaxBodySize` | 上传文件上限,默认 200MB | +| `vector.dim` | 向量维度,须与向量模型一致 | +| `database.cache.ttl` | DAO 查询缓存秒数 | + +## API 概览 + +| 分组 | 接口 | +|---|---| +| `/system-config` | 登录、设置读写、令牌查看/重新生成 | +| `/model-config` | 模型配置 CRUD、连通性测试 | +| `/dataset` | 数据集 CRUD | +| `/document` | 上传、列表、删除、重新向量化 | +| `/chunk` | 分块列表、编辑(改后自动重向量化) | +| `/parse-task` | 解析任务列表、失败重试 | +| `/conversation` `/message` | 会话管理、消息列表、SSE 问答流(`/message/chat`) | +| `/kg-entity` `/kg-relation` | 知识图谱实体/关系列表 | + +所有接口除 `/system-config/login` 外均需 `Authorization: Bearer `。 + +## 项目结构 + +``` +common/ 通用层:HTTP 服务/鉴权/文件解析/中文分词/向量 JSON +kb/ + consts/ 表名、状态、常量 + model/ entity / dto / domain + dao/ 数据访问(每表一个文件) + service/ 业务逻辑(每表一个文件 + chat_service 问答编排) + controller/ 接口层(每表一个文件) +ui-src/ Vue 3 前端 +docs/ 实现方案文档 +``` diff --git a/common/auth.go b/common/auth.go new file mode 100644 index 0000000..02f48f7 --- /dev/null +++ b/common/auth.go @@ -0,0 +1,47 @@ +package common + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const jwtSecret = "rag-local-jwt-secret-2026" + +const TokenExpireSeconds = 24 * 3600 + +type JwtClaims struct { + Role string `json:"role"` + TokenFp string `json:"token_fp"` + jwt.RegisteredClaims +} + +func GetJwtSecret() string { + return jwtSecret +} + +func SignToken(role, tokenFp string, expireSeconds int64) (string, error) { + claims := JwtClaims{ + Role: role, + TokenFp: tokenFp, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireSeconds) * time.Second)), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(jwtSecret)) +} + +func ParseToken(tokenStr string) (*JwtClaims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &JwtClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(jwtSecret), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*JwtClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} diff --git a/common/auth_middleware.go b/common/auth_middleware.go new file mode 100644 index 0000000..573cc29 --- /dev/null +++ b/common/auth_middleware.go @@ -0,0 +1,81 @@ +package common + +import ( + "context" + "net/http" + "strings" + + "github.com/gogf/gf/v2/net/ghttp" +) + +var publicPaths = []string{ + "/system-config/login", +} + +// CheckTokenFingerprint 由 kb/service 注入,避免 common → service 循环依赖 +var CheckTokenFingerprint func(ctx context.Context, fp string) bool + +func Auth(r *ghttp.Request) { + path := r.URL.Path + + // 公开路径(精确匹配) + for _, p := range publicPaths { + if path == p { + r.Middleware.Next() + return + } + } + + // workspace 源文件前缀放行(浏览器下载/预览请求不带 Authorization) + if strings.HasPrefix(path, "/workspace/") { + r.Middleware.Next() + return + } + + // 前端静态资源放行:仅 GET / 与 GET /assets/*(hash 路由下 SPA 只请求这两个路径) + if r.Method == http.MethodGet && (path == "/" || strings.HasPrefix(path, "/assets/")) { + r.Middleware.Next() + return + } + + auth := r.Header.Get("Authorization") + if auth == "" || !strings.HasPrefix(auth, "Bearer ") { + r.Response.WriteJson(ghttp.DefaultHandlerResponse{ + Code: http.StatusUnauthorized, + Message: "未登录或登录已过期", + }) + r.Exit() + return + } + + claims, err := ParseToken(auth[7:]) + if err != nil { + r.Response.WriteJson(ghttp.DefaultHandlerResponse{ + Code: http.StatusUnauthorized, + Message: "登录已过期,请重新登录", + }) + r.Exit() + return + } + + // 指纹校验:访问令牌被重新生成后,旧会话立即失效 + if CheckTokenFingerprint != nil && !CheckTokenFingerprint(r.Context(), claims.TokenFp) { + r.Response.WriteJson(ghttp.DefaultHandlerResponse{ + Code: http.StatusUnauthorized, + Message: "访问令牌已变更,请重新登录", + }) + r.Exit() + return + } + + r.SetCtxVar("role", claims.Role) + r.Middleware.Next() +} + +func GetRole(r *ghttp.Request) string { + v := r.GetCtxVar("role") + if v == nil { + return "" + } + return v.String() +} diff --git a/common/base_dao.go b/common/base_dao.go new file mode 100644 index 0000000..6bff01c --- /dev/null +++ b/common/base_dao.go @@ -0,0 +1,55 @@ +package common + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" +) + +func prepareInsertData(data any) map[string]any { + m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}}) + delete(m, "id") + m["created_at"] = gtime.Now().Format("Y-m-d H:i:s") + m["updated_at"] = gtime.Now().Format("Y-m-d H:i:s") + delete(m, "deleted_at") + return m +} + +func InsertAndReturnId(ctx context.Context, table string, data any) (id int64, err error) { + m := prepareInsertData(data) + r, err := g.DB().Model(table).Ctx(ctx).Data(m).Insert() + if err != nil { + return 0, err + } + if r == nil { + return 0, nil + } + return r.LastInsertId() +} + +func GetOneByPk[T any](ctx context.Context, table string, pk int64) (res *T, err error) { + r, err := g.DB().Model(table).Ctx(ctx). + Cache(gdb.CacheOption{Duration: CacheTTL(), Name: table + "_GetOneByPk_" + gconv.String(pk)}). + Where("id", pk).One() + if err != nil { + return nil, err + } + if r == nil { + return nil, nil + } + err = r.Struct(&res) + return +} + +func UpdateByPk(ctx context.Context, table string, pk int64, data any) error { + _, err := g.DB().Model(table).Ctx(ctx).Data(data).Where("id", pk).Update() + return err +} + +func DeleteByPk(ctx context.Context, table string, pk int64) error { + _, err := g.DB().Model(table).Ctx(ctx).Unscoped().Where("id", pk).Delete() + return err +} diff --git a/common/cache.go b/common/cache.go new file mode 100644 index 0000000..8251e9c --- /dev/null +++ b/common/cache.go @@ -0,0 +1,22 @@ +package common + +import ( + "context" + "sync" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +var ( + cacheTTL time.Duration + cacheTTLOnce sync.Once +) + +// CacheTTL returns the database query cache TTL from config +func CacheTTL() time.Duration { + cacheTTLOnce.Do(func() { + cacheTTL = time.Duration(g.Cfg().MustGet(context.Background(), "database.cache.ttl", 60).Int()) * time.Second + }) + return cacheTTL +} diff --git a/common/docx_parser.go b/common/docx_parser.go new file mode 100644 index 0000000..d5af1d9 --- /dev/null +++ b/common/docx_parser.go @@ -0,0 +1,87 @@ +package common + +import ( + "archive/zip" + "io" + "strings" +) + +type docxParser struct{} + +// Parse docx:解 zip 读 word/document.xml,按 段落提取文本 +func (p *docxParser) Parse(path string) (string, error) { + r, err := zip.OpenReader(path) + if err != nil { + return "", err + } + defer r.Close() + + var sb strings.Builder + for _, f := range r.File { + if f.Name != "word/document.xml" { + continue + } + rc, err := f.Open() + if err != nil { + return "", err + } + body, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return "", err + } + extractDocxText(string(body), &sb) + return sb.String(), nil + } + return "", nil +} + +// extractDocxText 解析 document.xml:每个 一段,段内文本去 XML 标签 +func extractDocxText(xml string, sb *strings.Builder) { + for { + start := strings.Index(xml, "") { + continue + } + value := seg[1:] + if j := strings.Index(value, ""); j >= 0 { + value = value[:j] + } + paraSb.WriteString(value) + } + line := strings.TrimSpace(paraSb.String()) + if line != "" { + sb.WriteString(line) + sb.WriteString("\n") + } + } +} + +// findTagEnd 找到 对应的 结束位置(返回开标签之后的位置) +func findTagEnd(xml string, start int) int { + openEnd := strings.Index(xml[start:], ">") + if openEnd < 0 { + return -1 + } + closeTag := strings.Index(xml[start:], "") + if closeTag < 0 { + return -1 + } + return start + closeTag + len("") +} diff --git a/common/html_parser.go b/common/html_parser.go new file mode 100644 index 0000000..79af46a --- /dev/null +++ b/common/html_parser.go @@ -0,0 +1,34 @@ +package common + +import ( + "os" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +type htmlParser struct{} + +// Parse html:取 body 文本,标题(h1-h6)前后补空行供分块器识别 +func (p *htmlParser) Parse(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(b))) + if err != nil { + return "", err + } + var sb strings.Builder + doc.Find("body").Children().Each(func(i int, sel *goquery.Selection) { + switch goquery.NodeName(sel) { + case "h1", "h2", "h3", "h4", "h5", "h6", "p", "div", "li", "pre", "blockquote": + text := strings.TrimSpace(sel.Text()) + if text != "" { + sb.WriteString(text) + sb.WriteString("\n\n") + } + } + }) + return sb.String(), nil +} diff --git a/common/http.go b/common/http.go new file mode 100644 index 0000000..2823829 --- /dev/null +++ b/common/http.go @@ -0,0 +1,49 @@ +package common + +import ( + "fmt" + "reflect" + "regexp" + "strings" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gtime" +) + +var Httpserver = g.Server() + +func init() { + err := gtime.SetTimeZone("Asia/Shanghai") + if err != nil { + panic("设置时区失败") + } + Httpserver.SetOpenApiPath("/api.json") + // 全局 panic 恢复(最先注册,作为最外层包裹) + Httpserver.BindMiddlewareDefault(ghttp.MiddlewareHandlerResponse) + // CORS - allow all origins + Httpserver.BindMiddlewareDefault(func(r *ghttp.Request) { + r.Response.CORS(r.Response.DefaultCORSOptions()) + r.Middleware.Next() + }) + // 访问令牌鉴权 + Httpserver.BindMiddlewareDefault(Auth) +} + +// RouteRegister 根据控制器结构体名称自动注册路由 +func RouteRegister(controllers []interface{}) { + re := regexp.MustCompile("[A-Z]") + for _, t := range controllers { + sName := reflect.ValueOf(t).Elem().Type().Name() + convertedStr := re.ReplaceAllStringFunc(sName, func(s string) string { + return fmt.Sprintf("-%s", strings.ToLower(s)) + }) + if len(convertedStr) > 0 && convertedStr[0] == '-' { + convertedStr = convertedStr[1:] + } + Httpserver.Group("/"+convertedStr, func(group *ghttp.RouterGroup) { + group.Bind(t) + }) + } + go Httpserver.Run() +} diff --git a/common/parser.go b/common/parser.go new file mode 100644 index 0000000..80f217f --- /dev/null +++ b/common/parser.go @@ -0,0 +1,53 @@ +package common + +import ( + "fmt" + "strings" +) + +// Parser 文档解析器接口:从源文件抽取纯文本 +type Parser interface { + // Parse 返回文档全文(保留标题/段落/空行结构,供分块器使用) + Parse(path string) (string, error) +} + +var parsers = map[string]Parser{} + +func init() { + registerParser("txt", &textParser{}) + registerParser("md", &textParser{}) + registerParser("pdf", &pdfParser{}) + registerParser("docx", &docxParser{}) + registerParser("html", &htmlParser{}) +} + +func registerParser(ext string, p Parser) { + parsers[ext] = p +} + +// SupportedExts 返回支持的扩展名列表(不含点) +func SupportedExts() []string { + exts := make([]string, 0, len(parsers)) + for ext := range parsers { + exts = append(exts, ext) + } + return exts +} + +// ParseFile 按扩展名分发解析,返回全文 +func ParseFile(path string) (string, error) { + ext := strings.TrimPrefix(extOf(path), ".") + p, ok := parsers[ext] + if !ok { + return "", fmt.Errorf("不支持的文档类型: .%s", ext) + } + return p.Parse(path) +} + +func extOf(path string) string { + idx := strings.LastIndexByte(path, '.') + if idx < 0 { + return "" + } + return path[idx:] +} diff --git a/common/parser_test.go b/common/parser_test.go new file mode 100644 index 0000000..93a4357 --- /dev/null +++ b/common/parser_test.go @@ -0,0 +1,97 @@ +package common + +import ( + "archive/zip" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseTxt(t *testing.T) { + path := filepath.Join(t.TempDir(), "a.txt") + if err := os.WriteFile(path, []byte("第一行\n\n## 标题\n第二行"), 0o644); err != nil { + t.Fatal(err) + } + text, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + for _, part := range []string{"第一行", "## 标题", "第二行"} { + if !strings.Contains(text, part) { + t.Errorf("missing %q in %q", part, text) + } + } +} + +func TestParseHtml(t *testing.T) { + path := filepath.Join(t.TempDir(), "b.html") + html := `

文档标题

正文第一段。

正文第二段。

` + if err := os.WriteFile(path, []byte(html), 0o644); err != nil { + t.Fatal(err) + } + text, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + for _, part := range []string{"文档标题", "正文第一段", "正文第二段"} { + if !strings.Contains(text, part) { + t.Errorf("missing %q in %q", part, text) + } + } +} + +func TestParseDocx(t *testing.T) { + path := filepath.Join(t.TempDir(), "c.docx") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + w, err := zw.Create("word/document.xml") + if err != nil { + t.Fatal(err) + } + xml := `第一段落第二段落` + if _, err := w.Write([]byte(xml)); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + f.Close() + + text, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + for _, part := range []string{"第一段落", "第二段落"} { + if !strings.Contains(text, part) { + t.Errorf("missing %q in %q", part, text) + } + } +} + +func TestParsePdf(t *testing.T) { + path := "/Users/zhangbin/go/pkg/mod/github.com/pdfcpu/pdfcpu@v0.14.0/pkg/testdata/testRot.pdf" + if _, err := os.Stat(path); err != nil { + t.Skip("pdfcpu testdata not found") + } + text, err := ParseFile(path) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(text) == "" { + t.Fatal("pdf text is empty") + } +} + +func TestParseUnsupported(t *testing.T) { + path := filepath.Join(t.TempDir(), "d.xyz") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ParseFile(path); err == nil { + t.Fatal("expected error for unsupported type") + } +} diff --git a/common/pdf_parser.go b/common/pdf_parser.go new file mode 100644 index 0000000..b187863 --- /dev/null +++ b/common/pdf_parser.go @@ -0,0 +1,35 @@ +package common + +import ( + "io" + "os" + "strings" + + "github.com/pdfcpu/pdfcpu/pkg/api" +) + +type pdfParser struct{} + +// Parse PDF:抽取全部文本,按页分隔(分页符供分块器识别) +func (p *pdfParser) Parse(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + var sb strings.Builder + err = api.ExtractContent(f, nil, func(r io.Reader, _ int) error { + b, err := io.ReadAll(r) + if err != nil { + return err + } + sb.Write(b) + sb.WriteString("\n\n") + return nil + }, nil) + if err != nil { + return "", err + } + return sb.String(), nil +} diff --git a/common/text_parser.go b/common/text_parser.go new file mode 100644 index 0000000..05462e8 --- /dev/null +++ b/common/text_parser.go @@ -0,0 +1,20 @@ +package common + +import ( + "os" + "strings" +) + +type textParser struct{} + +// Parse txt / md:直接读全文。md 保留 # 标题行供分块器识别 +func (p *textParser) Parse(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + text := string(b) + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + return text, nil +} diff --git a/common/tokenizer.go b/common/tokenizer.go new file mode 100644 index 0000000..a598210 --- /dev/null +++ b/common/tokenizer.go @@ -0,0 +1,47 @@ +package common + +import ( + "strings" + + "github.com/go-ego/gse" +) + +var seg gse.Segmenter + +func init() { + if err := seg.LoadDict(); err != nil { + panic("gse load dict failed: " + err.Error()) + } +} + +// Tokenize 中文分词,空格连接(FTS5 索引列格式:content_tokens) +func Tokenize(text string) string { + tokens := seg.Cut(text, true) + var sb strings.Builder + for _, t := range tokens { + t = strings.TrimSpace(t) + if t == "" { + continue + } + sb.WriteString(t) + sb.WriteString(" ") + } + return strings.TrimSpace(sb.String()) +} + +// TokenizeQuery FTS5 查询串:每个词用双引号包裹(AND 语义),过滤 FTS5 特殊字符 +func TokenizeQuery(text string) string { + tokens := seg.Cut(text, true) + var parts []string + for _, t := range tokens { + t = strings.TrimSpace(t) + if t == "" { + continue + } + if strings.ContainsAny(t, "\"*:()") { + continue + } + parts = append(parts, "\""+t+"\"") + } + return strings.Join(parts, " ") +} diff --git a/common/util.go b/common/util.go new file mode 100644 index 0000000..414827a --- /dev/null +++ b/common/util.go @@ -0,0 +1,22 @@ +package common + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" +) + +// RandomToken 生成 n 字节随机数的十六进制字符串(2n 位) +func RandomToken(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + return hex.EncodeToString(b) +} + +// TokenFingerprint 计算令牌 SHA-256 指纹前 16 位,用于 JWT 会话校验 +func TokenFingerprint(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:8]) +} diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..e27927f --- /dev/null +++ b/config.yml @@ -0,0 +1,30 @@ +database: + default: + name: data/business.db + type: sqlite + debug: false + system: + name: data/system.db + type: sqlite + debug: false + chat: + name: data/chat.db + type: sqlite + debug: false + cache: + ttl: 60 # DAO查询缓存时间(秒),0为禁用缓存 +server: + address: :8080 + name: rag-local + workerId: 1 + clientMaxBodySize: 209715200 # 200MB 上传文件限制 + requestTimeout: 3000 # HTTP请求超时(秒),支持 AI 长响应 + +# 向量配置 +vector: + dim: 1024 # 向量维度(须与所用 embedding 模型一致,变更需清库重建) + +# AI模型调用配置 +chat: + timeout: 600 # 对话模型API请求超时时间(秒) + max_retries: 3 # 请求失败最大重试次数 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6bdea6b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + rag-local: + build: . + container_name: rag-local + restart: unless-stopped + ports: + - "8080:8080" + volumes: + # 数据目录(三个 SQLite 库)与文档工作区持久化 + - ./data:/app/data + - ./workspace:/app/workspace + environment: + - TZ=Asia/Shanghai diff --git a/docs/实现方案.md b/docs/实现方案.md new file mode 100644 index 0000000..1941b6c --- /dev/null +++ b/docs/实现方案.md @@ -0,0 +1,881 @@ +# rag-local 本地知识库 实现方案 + +> 技术栈:GoFrame v2 + Vue 3 + Eino(字节 CloudWeGo LLM 编排框架) +> 数据库:全部文件型 —— SQL = SQLite,向量 = sqlite-vec(SQLite 扩展),全文检索 = SQLite FTS5 +> 部署:docker-compose 一键部署,前后端不分离,统一端口暴露 +> 鉴权:单用户 + 启动生成的访问令牌(面向边缘设备部署) + +--- + +## 1. 项目概述 + +本地私有知识库系统(面向 NAS / 小主机等边缘设备单机部署),支持: + +- 文档管理:上传(txt / md / pdf / docx / html)、自动解析、分块、向量化 +- 混合检索:语义(向量)检索 + 关键词(全文)检索,RRF 融合排序 +- RAG 问答:基于 Eino 编排的流式对话,答案附带引用来源 +- 模型可配置:对话模型 / 嵌入模型均为 OpenAI 兼容 API,可对接任意供应商(本地 Ollama、DeepSeek、硅基流动、OpenAI 等) +- 单用户访问令牌登录:token 首次启动随机生成并打印在控制台,登录页输入即可使用 + +设计原则: + +1. **全文件型数据库**:业务 SQL、向量、全文检索全部落地为普通文件,随数据目录一键备份/迁移,无任何外部数据库服务 +2. **按业务领域分库文件**:系统 / 数据集 / 问答 三个 SQLite 文件,互不干扰 +3. **分层文件与表对齐**:每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(参照 video-factory 17 表 × 5 层的组织方式) +4. **前后端不分离**:Vue 构建产物由 GoFrame 统一端口托管,单容器部署 +5. **零配置**:不设用户体系与角色权限(单机单人场景),启动即用 + +--- + +## 2. 技术选型 + +| 能力 | 选型 | 说明 | +|---|---|---| +| Web 框架 | GoFrame v2.10+ | 与 video-factory 一致,复用其分层/路由/鉴权模式 | +| SQL 数据库 | SQLite(modernc.org/sqlite 纯 Go 实现) | GoFrame `contrib/drivers/sqlite/v2`,免 CGO,`CGO_ENABLED=0` | +| 向量检索 | **sqlite-vec**(`modernc.org/sqlite/vec` 子包) | modernc 从 **v1.47.0 起内置** sqlite-vec 的 CGO-free 版本,`vec0` 虚拟表提供 KNN 检索;与业务表**同库同事务**,天然文件型 | +| 全文检索 | SQLite FTS5(modernc 内置) | BM25 打分;中文通过**应用层分词**(纯 Go `github.com/go-ego/gse`)写入分词列,simple tokenizer 索引 | +| LLM 编排 | Eino `github.com/cloudwego/eino` | ChatModel / Embedding 自研 OpenAI 兼容 HTTP 实现(eino-ext 为空壳,仅依赖 eino 接口);Indexer / Retriever 自研(SQLite 后端),实现 Eino 标准接口 | +| 文档解析 | pdfcpu(pdf)、纯 Go zip+xml 解析(docx)、goquery(html)、标准库(txt/md) | 全部纯 Go,免 CGO | +| 前端 | Vue 3 + Vite + Element Plus + axios | hash 路由,构建产物 `ui-src/dist`,由 Go 统一端口托管 | +| 部署 | Docker 多阶段构建 + docker-compose | 单服务单端口,数据目录挂载宿主机卷 | + +### 2.1 关键版本说明(向量扩展) + +- sqlite-vec 需要 `modernc.org/sqlite >= v1.47.0`(2026-03-17 起内置,免 CGO) +- GoFrame v2.10.2 驱动链默认锁定 `modernc.org/sqlite v1.23.1`(过旧),**必须在 go.mod 中显式升级**: + ```bash + go get modernc.org/sqlite@v1.47.0 + ``` + Go modules 最小版本选择(MVS)会使全链路统一使用 v1.47+;glebarez 为薄封装,API 兼容。主程序只需: + ```go + import _ "modernc.org/sqlite/vec" // 空导入,init 自动注册 vec0 扩展 + ``` +- **风险与验证**:升级后首步执行 `go build` 验证 glebarez 与 v1.47 兼容;若出现编译错误,启用备选方案(见 §14.1) + +--- + +## 3. 总体架构 + +``` +┌─────────────────────────── 浏览器 ───────────────────────────┐ +│ http://host:8080 │ +└──────────────────────────────┬───────────────────────────────┘ + │ 统一端口(SPA 静态资源 + REST API + SSE 流式) +┌──────────────────────────────▼───────────────────────────────┐ +│ GoFrame HTTP Server (:8080) │ +│ ┌───────────┐ ┌──────────────┐ ┌────────────────────────┐ │ +│ │ 静态资源 │ │ API 路由 │ │ 访问令牌鉴权 / CORS / │ │ +│ │ ui-src/dist│ │ /dataset │ │ panic 恢复 中间件 │ │ +│ └───────────┘ │ /document ... │ └────────────────────────┘ │ +│ └──────┬───────┘ │ +│ ┌──────────────┼──────────────┐ │ +│ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼──────┐ │ +│ │ controller │→│ service │→│ dao │ │ +│ │ (10 个文件) │ │ (10 个文件) │ │ (10 个文件) │ │ +│ └────────────┘ └──────┬─────┘ └──────┬──────┘ │ +│ │ │ │ +│ ┌─────────▼──────┐ ┌────▼─────────────────────┐ │ +│ │ Eino RAG 编排 │ │ SQLite × 3(文件型) │ │ +│ │ Graph/Chain │ │ business.db: 业务表+向量+ │ │ +│ │ Indexer/Retr. │ │ FTS(vec0 + fts5) │ │ +│ │ ChatModel │ │ system.db: 令牌/配置 │ │ +│ │ Embedding │ │ chat.db: 会话/消息 │ │ +│ └───────┬────────┘ └───────────────────────────┘ │ +│ │ HTTP(OpenAI 兼容 API) │ +└──────────────────────┼────────────────────────────────────────┘ + ┌────────▼────────┐ + │ 模型供应商(可配置)│ + │ Ollama/OpenAI/ │ + │ DeepSeek/硅基流动 │ + └─────────────────┘ +``` + +数据流: + +``` +上传文档 → 解析文本 → 分块 → Embedding 向量化 ──┐ + ├─→ business.db(chunk 表 + vec0 + fts5 同库同事务) +关键词检索(FTS5 BM25)───────────────────────┤ + ↓ +问题 → 混合检索(向量 + 关键词 + RRF 融合)→ 组装上下文 → ChatModel 流式回答 → SSE +``` + +--- + +## 4. 目录结构 + +``` +rag-local/ +├── main.go # 入口:路由注册、静态资源托管、任务轮询启动、访问令牌生成/打印 +├── config.yml # 多库配置 + 服务配置 +├── go.mod / go.sum +├── Dockerfile # 多阶段构建(ui-builder → builder → runtime) +├── docker-compose.yml # 一键部署,挂载数据卷 +├── common/ # 公共层(与 video-factory 对齐) +│ ├── http.go # RouteRegister 自动路由 + OpenAPI + 中间件注册 +│ ├── auth.go # JWT 签发/解析 + 访问令牌指纹校验 +│ ├── auth_middleware.go # 鉴权中间件(白名单 + 静态资源放行) +│ ├── base_dao.go # InsertAndReturnId / GetOneByPk / UpdateByPk / DeleteByPk +│ ├── cache.go # DAO 查询缓存 TTL(database.cache.ttl) +│ ├── util.go # 通用工具 +│ ├── parser.go # 解析器接口 + 注册表(按扩展名分发) +│ ├── pdf_parser.go # pdfcpu 解析 PDF +│ ├── docx_parser.go # zip+xml 解析 word/document.xml +│ ├── html_parser.go # goquery 解析 HTML +│ ├── text_parser.go # txt / md +│ └── tokenizer.go # gse 中文分词(写索引/检索共用) +├── kb/ # 业务模块(knowledge base,对应 video-factory 的 shortdrama) +│ ├── consts/ +│ │ ├── table_name.go # 表名常量 + 数据库组常量(DbGroupSystem 等) +│ │ ├── status.go # 文档状态 / 任务状态 / 消息角色 +│ │ ├── content_type.go # 文件类型 / 模型类型(chat/embedding) +│ │ └── consts.go # 通用常量(向量维度默认值、TopK、access_token 配置键) +│ ├── model/ +│ │ ├── entity/ # 10 个文件,与 10 张实体表一一对应 +│ │ ├── dto/ # 10 个文件,与 entity 对应(含 g.Meta 路由定义) +│ │ └── domain/ # 领域对象(RAG 检索结果、引用来源、流式事件等) +│ ├── dao/ # 10 个文件,与实体表一一对应 +│ ├── service/ # 10 个文件,与 dao 对应(文档流水线、RAG 问答编排归入对应 service) +│ ├── controller/ # 10 个文件,与 service 对应 +├── ui-src/ # Vue 3 前端工程 +│ ├── package.json +│ ├── vite.config.js # base:'/'、build.outDir:'dist' +│ └── src/ +│ ├── main.js / App.vue +│ ├── router/index.js # hash 路由(登录守卫) +│ ├── api/ # axios 封装 + 各模块 API(与后端 dto 对齐) +│ ├── store/ # Pinia(auth / dataset 状态) +│ ├── views/ +│ │ ├── Login.vue # 输入访问令牌登录 +│ │ ├── Layout.vue +│ │ ├── DatasetList.vue # 数据集列表 +│ │ ├── DatasetDetail.vue # 文档管理 + 上传 + 解析状态 +│ │ ├── Chat.vue # RAG 问答(SSE 流式 + 引用来源) +│ │ └── Settings.vue # 模型配置 + 访问令牌管理 +│ └── components/ +│ ├── DocumentUpload.vue +│ ├── ChunkList.vue +│ └── MessageBubble.vue # 流式消息 + 引用折叠面板 +├── data/ # 仅数据库文件(gitignore) +│ ├── business.db # 数据集领域 +│ ├── system.db # 系统领域 +│ └── chat.db # 问答领域 +├── workspace/ # 上传的文档源文件(gitignore,与数据库分离) +│ └── {datasetId}/{yyyymmdd}/{uuid}.{ext} +└── docs/ + └── 实现方案.md +``` + +> 分层文件与表对齐规则(硬性约定,参照 video-factory): +> - `model/entity/` 每个文件定义一张实体表的结构体,`orm` 标签命名 +> - `dao/` 每个文件 = 一张表的单例 DAO(`var Xxx = &xxxDao{}`),`init()` 内建表 + 索引 + 迁移 +> - `service/` 每个文件对应一个 DAO,承载业务逻辑(文档流水线、RAG 调用) +> - `controller/` 每个文件对应一个 service,暴露 REST 接口(`g.Meta` 定义 path/method) +> - `model/dto/` 每个文件定义一张表的 Req/Res 结构体 +> - 虚拟表(vec0 / FTS5)是 chunk 表的附属索引,**不单独建分层文件**,由 `chunk_dao.go` 统一管理 +> - **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词)平铺在 `common/`;业务编排(分块、Indexer、Retriever、工作流)归入对应 service 文件 + +--- + +## 5. 数据库设计 + +### 5.1 数据库文件划分(按业务领域) + +| 文件 | 配置组名 | 领域 | 说明 | +|---|---|---|---| +| `data/business.db` | `default` | 数据集 | 数据集、文档、分块、向量(vec0)、全文索引(FTS5)、解析任务 | +| `data/system.db` | `system` | 系统 | 访问令牌、模型配置、系统配置 | +| `data/chat.db` | `chat` | 问答 | 会话、消息 | + +config.yml: + +```yaml +database: + default: + name: data/business.db + type: sqlite + debug: true + system: + name: data/system.db + type: sqlite + debug: true + chat: + name: data/chat.db + type: sqlite + debug: true + cache: + ttl: 60 + +server: + address: :8080 + name: rag-local + workerId: 1 + clientMaxBodySize: 209715200 # 200MB,支持大文件上传 + requestTimeout: 3000 # 秒;支持 AI 长响应 +``` + +### 5.2 表清单(10 张实体表 + 2 张虚拟表) + +| # | 表名 | 库 | 类型 | 说明 | +|---|---|---|---|---| +| 1 | `system_config` | system | 实体 | 访问令牌、默认模型等键值配置 | +| 2 | `model_config` | system | 实体 | 模型配置(chat / embedding) | +| 3 | `kb_dataset` | business | 实体 | 数据集 | +| 4 | `kb_document` | business | 实体 | 文档 | +| 5 | `kb_chunk` | business | 实体 | 分块 | +| 6 | `kb_chunk_vec` | business | **虚拟表 vec0** | 分块向量(chunk_id 与 kb_chunk 1:1) | +| 7 | `kb_chunk_fts` | business | **虚拟表 FTS5** | 分块全文索引 | +| 8 | `kb_parse_task` | business | 实体 | 文档解析/向量化任务 | +| 9 | `kg_entity` | business | 实体 | 知识图谱实体(LLM 抽取) | +| 10 | `kg_relation` | business | 实体 | 知识图谱关系(头实体-关系-尾实体) | +| 11 | `chat_conversation` | chat | 实体 | 问答会话 | +| 12 | `chat_message` | chat | 实体 | 问答消息 | + +> 实体表 10 张 → `entity / dao / service / controller / dto` 各 10 个文件,严格对齐。 +> 无 user / login_log / 角色权限:单用户场景,访问令牌存于 system_config。 +> 知识图谱(`kg_entity` / `kg_relation`)属数据集领域,见 §7.7。 +> 注:用 `sqlite3 .tables` 会看到 `kb_chunk_fts_*`(5 张)与 `kb_chunk_vec_*`(4 张)等额外表, +> 它们是 FTS5 / vec0 虚拟表自动生成的**内部影子表**(倒排索引、向量分块等存储),由 SQLite 自动维护, +> 不是业务表,不可删除,删除会导致虚拟表损坏。 + +### 5.3 建表 DDL + +表名前缀:`kb_`(数据集领域)、`chat_`(问答领域);`consts.TableNameXxx` 常量集中管理,参照 video-factory。 + +```sql +-- ==================== system.db ==================== + +CREATE TABLE IF NOT EXISTS system_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cfg_key TEXT NOT NULL DEFAULT '', + cfg_value TEXT NOT NULL DEFAULT '', + updated_at DATETIME DEFAULT (datetime('now','localtime')) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_system_config_key ON system_config(cfg_key); + +-- 预置键: +-- access_token 访问令牌(首次启动生成,明文存储,见 §8) +-- default_chat_model 默认对话模型配置 id +-- default_dataset 默认问答数据集 id + +CREATE TABLE IF NOT EXISTS model_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', -- 配置名称,如 "DeepSeek Chat" + model_type TEXT NOT NULL DEFAULT 'chat', -- chat / embedding + model_name TEXT NOT NULL DEFAULT '', -- 模型名,如 deepseek-chat + endpoint_url TEXT NOT NULL DEFAULT '', -- 如 https://api.deepseek.com + api_key TEXT NOT NULL DEFAULT '', + dimension INTEGER NOT NULL DEFAULT 1024, -- 仅 embedding 有效(向量维度) + extra TEXT NOT NULL DEFAULT '', -- JSON 扩展(temperature 等) + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) +); + +-- ==================== business.db ==================== + +CREATE TABLE IF NOT EXISTS kb_dataset ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + embedding_cfg_id INTEGER NOT NULL DEFAULT 0, -- 绑定 embedding 模型配置;切换需重新向量化 + status INTEGER NOT NULL DEFAULT 1, -- 1 正常 / 0 禁用 + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) +); + +CREATE TABLE IF NOT EXISTS kb_document ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dataset_id INTEGER NOT NULL DEFAULT 0, + filename TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', -- workspace 下相对路径(workspace/3/20260804/xxx.pdf) + file_size INTEGER NOT NULL DEFAULT 0, + file_type TEXT NOT NULL DEFAULT '', -- txt/md/pdf/docx/html + status INTEGER NOT NULL DEFAULT 0, -- 0 待处理 1 处理中 2 完成 3 失败 + chunk_count INTEGER NOT NULL DEFAULT 0, + error_msg TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) +); + +CREATE TABLE IF NOT EXISTS kb_chunk ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dataset_id INTEGER NOT NULL DEFAULT 0, + document_id INTEGER NOT NULL DEFAULT 0, + seq INTEGER NOT NULL DEFAULT 0, -- 分块序号 + content TEXT NOT NULL DEFAULT '', -- 分块原文 + meta TEXT NOT NULL DEFAULT '', -- JSON:来源页码/标题路径等 + created_at DATETIME DEFAULT (datetime('now','localtime')) +); +CREATE INDEX IF NOT EXISTS idx_kb_chunk_document ON kb_chunk(document_id); + +-- 向量虚拟表(sqlite-vec,维度按 embedding 模型定,默认 1024) +CREATE VIRTUAL TABLE IF NOT EXISTS kb_chunk_vec USING vec0( + chunk_id INTEGER PRIMARY KEY, + embedding float[1024] +); + +-- 全文索引虚拟表(FTS5,默认 unicode61 tokenizer + 应用层 gse 中文分词; +-- FTS5 不支持 simple tokenizer,中文分词在应用层完成,content_tokens 存分词后空格连接文本) +CREATE VIRTUAL TABLE IF NOT EXISTS kb_chunk_fts USING fts5( + chunk_id UNINDEXED, + dataset_id UNINDEXED, + title, + content_tokens -- gse 分词后空格连接 +); + +CREATE TABLE IF NOT EXISTS kb_parse_task ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id INTEGER NOT NULL DEFAULT 0, + dataset_id INTEGER NOT NULL DEFAULT 0, + task_type TEXT NOT NULL DEFAULT 'parse', -- parse / reembed + status INTEGER NOT NULL DEFAULT 0, -- 0 待处理 1 处理中 2 完成 3 失败 + error_msg TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) +); +CREATE INDEX IF NOT EXISTS idx_kb_parse_task_status ON kb_parse_task(status); + +-- 知识图谱(LLM 抽取,见 §7.7) +CREATE TABLE IF NOT EXISTS kg_entity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dataset_id INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', -- 实体名 + entity_type TEXT NOT NULL DEFAULT '', -- 类型(person/org/location/...) + meta TEXT NOT NULL DEFAULT '', -- JSON 扩展 + created_at DATETIME DEFAULT (datetime('now','localtime')) +); +CREATE INDEX IF NOT EXISTS idx_kg_entity_dataset_name ON kg_entity(dataset_id, name); + +CREATE TABLE IF NOT EXISTS kg_relation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dataset_id INTEGER NOT NULL DEFAULT 0, + head_id INTEGER NOT NULL DEFAULT 0, -- 头实体 kg_entity.id + relation TEXT NOT NULL DEFAULT '', -- 关系类型 + tail_id INTEGER NOT NULL DEFAULT 0, -- 尾实体 kg_entity.id + meta TEXT NOT NULL DEFAULT '', -- JSON:来源 chunk、置信度 + created_at DATETIME DEFAULT (datetime('now','localtime')) +); +CREATE INDEX IF NOT EXISTS idx_kg_relation_head ON kg_relation(head_id); +CREATE INDEX IF NOT EXISTS idx_kg_relation_tail ON kg_relation(tail_id); + +-- ==================== chat.db ==================== + +CREATE TABLE IF NOT EXISTS chat_conversation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dataset_id INTEGER NOT NULL DEFAULT 0, -- 问答绑定的数据集 + title TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) +); + +CREATE TABLE IF NOT EXISTS chat_message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL DEFAULT 0, + role TEXT NOT NULL DEFAULT 'user', -- user / assistant / system + content TEXT NOT NULL DEFAULT '', + citations TEXT NOT NULL DEFAULT '', -- JSON:引用来源(文档/分块/得分) + created_at DATETIME DEFAULT (datetime('now','localtime')) +); +CREATE INDEX IF NOT EXISTS idx_chat_message_conversation ON chat_message(conversation_id); +``` + +### 5.4 关键设计决策 + +1. **向量与业务同库同事务**:`kb_chunk_vec` 与 `kb_chunk` 同在 business.db。删除文档时,`chunk → vec → fts` 在一个事务内删除,无一致性问题。 +2. **数据库与源文件分离**:`data/` 仅存 3 个 db 文件;上传源文件存 `workspace/`。备份 = 两个目录分别打包(db 小而关键、workspace 大而可重建),清理与迁移互不影响。 +3. **向量维度跟随模型**:`model_config.dimension` 决定 vec0 建表维度;数据集绑定 embedding 配置(`kb_dataset.embedding_cfg_id`)。切换 embedding 模型时前端提示"重新向量化",触发 `reembed` 任务(重建该库全部向量 + FTS)。 +4. **中文分词在应用层**:SQLite 内置 tokenizer 无法正确切分中文。写入 FTS5 时用 gse 分词后以空格连接存入 `content_tokens`;查询时对 query 同样分词。备选:trigram tokenizer(召回差但零依赖)。 +5. **FTS5 表自包含**:只存 `chunk_id + dataset_id + title + content_tokens`,原文在 `kb_chunk`,命中后回表 join 取原文与元数据,避免 FTS 表膨胀。 +6. **解析任务用表驱动**:`kb_parse_task` 轮询模式(与 video-factory 的 `StartVideoPoller` 一致),单 goroutine 串行消费,避免 SQLite 并发写冲突。 +7. **SQLite 并发**:3 个库文件各自独立连接;写操作集中在任务轮询 goroutine 与用户操作,使用 WAL 模式(`PRAGMA journal_mode=WAL`)提升读写并发。 + +--- + +## 6. 分层实现规范(参照 video-factory) + +### 6.1 consts 层 + +```go +// kb/consts/table_name.go +const ( + TableNameSystemConfig = "system_config" + TableNameModelConfig = "model_config" + TableNameDataset = "kb_dataset" + TableNameDocument = "kb_document" + TableNameChunk = "kb_chunk" + TableNameChunkVec = "kb_chunk_vec" + TableNameChunkFts = "kb_chunk_fts" + TableNameParseTask = "kb_parse_task" + TableNameConversation = "chat_conversation" + TableNameMessage = "chat_message" +) + +// 数据库组 +const ( + DbGroupDefault = "" // business.db + DbGroupSystem = "system" // system.db + DbGroupChat = "chat" // chat.db +) +``` + +### 6.2 entity 层 + +每文件一张表,`orm` 标签与列名一致,时间用 `*gtime.Time`(参照 video-factory 的 `user.go`): + +```go +type Chunk struct { + Id int64 `orm:"id" json:"id"` + DatasetId int64 `orm:"dataset_id" json:"dataset_id"` + DocumentId int64 `orm:"document_id" json:"document_id"` + Seq int `orm:"seq" json:"seq"` + Content string `orm:"content" json:"content"` + Meta string `orm:"meta" json:"meta"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} +``` + +### 6.3 dao 层 + +- 单例模式:`var Chunk = &chunkDao{}` +- `init()` 内执行 `CREATE TABLE IF NOT EXISTS` + 索引 + 迁移(参照 video-factory `user_dao.go`) +- 每表一个文件;`chunk_dao.go` 额外管理 `kb_chunk_vec`(向量 KNN 查询、批量插入)与 `kb_chunk_fts`(BM25 检索、分词写索引),并提供事务内删除(chunk+vec+fts) +- 查询缓存:`gdb.CacheOption{Duration: common.CacheTTL(), Name: ...}`,写操作后清理(参照 video-factory `clearUserCache`) +- 通用 CRUD 用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk / DeleteByPk) + +向量与全文检索 DAO 示例: + +```go +// 向量 KNN(余弦) +func (d *chunkDao) VecSearch(ctx context.Context, datasetId int64, vector []float32, topK int) ([]VecHit, error) { + vecStr := vectorToJson(vector) // [0.1,0.2,...] + r, err := g.DB(consts.DbGroupDefault).GetCtx(ctx).Raw( + "SELECT chunk_id, vec_distance_cosine(embedding, vec_f32(?)) AS d "+ + "FROM "+consts.TableNameChunkVec+" WHERE embedding MATCH ? ORDER BY d LIMIT ?", + vecStr, vecStr, topK, + ) + ... +} + +// 全文检索(BM25) +func (d *chunkDao) FtsSearch(ctx context.Context, datasetId int64, query string, topK int) ([]FtsHit, error) { + tokens := common.TokenizeQuery(query) + r, err := g.DB(consts.DbGroupDefault).GetCtx(ctx).Raw( + "SELECT chunk_id, bm25(kb_chunk_fts) AS score FROM kb_chunk_fts "+ + "WHERE kb_chunk_fts MATCH ? AND dataset_id = ? ORDER BY score LIMIT ?", + tokens, datasetId, topK, + ) + ... +} +``` + +### 6.4 service 层 + +每表一个文件 + 业务方法(解析与分词等纯技术能力在 `common/`,业务编排全部归入对应 service): +- `system_config_service.go`:**访问令牌**生成/校验/重新生成、系统设置读写 +- `dataset_service.go`:数据集 CRUD +- `document_service.go`:上传落盘、创建文档记录、提交解析任务(解析器调 `common/parser.go`) +- `chunk_service.go`:文本分块(splitter,标题感知+800字+100重叠)、`InsertAll`(批量向量化 + 写 chunk+vec0+FTS5,等价 Indexer 职责)、分块列表、编辑(改文本后重分词) +- `parse_task_service.go`:任务队列消费(`StartParsePoller`,与 video-factory `StartVideoPoller` 同模式;解析→分块→索引全流程编排) +- `conversation_service.go` / `message_service.go`:会话 CRUD、历史消息 +- `chat_service.go`:**RAG 问答入口**(模型组件工厂、`HybridRetriever`(vec0+FTS5+RRF)、问答工作流编排,SSE 流式回传,落库 message;检索调 `common/tokenizer.go` 分词) +- `model_config_service.go`:模型配置 CRUD + 连通性测试 + +### 6.5 controller 层 + dto 层 + +- 结构体命名决定路由前缀:`dataset` → `/dataset`,`system-config` → `/system-config`(`common.RouteRegister` 自动注册) +- 接口定义在 `model/dto/`,`g.Meta` 携带 path/method/summary;OpenAPI 文档自动生成(`/api.json`) +- 主要接口清单: + +| 路由 | 说明 | +|---|---| +| `POST /system-config/login` | **访问令牌登录**(body: {token} → 返回 JWT) | +| `GET/PUT /system-config` | 系统设置(默认模型、默认数据集) | +| `POST /system-config/regenerate-token` | 重新生成访问令牌(旧会话立即失效) | +| `GET/POST/PUT/DELETE /dataset` | 数据集 CRUD | +| `POST /document/upload`(multipart)`DELETE /document` `GET /document/list` | 文档管理 | +| `GET /document/{id}/chunks` `PUT /chunk` | 分块查看/编辑 | +| `POST /document/{id}/reembed` | 重新向量化 | +| `GET /parse-task/list` `POST /parse-task/{id}/retry` | 任务管理 | +| `GET/POST/DELETE /conversation` | 会话 CRUD | +| `GET /message/list` | 历史消息 | +| **`POST /message/chat`** | **RAG 问答(SSE 流式)** | +| `GET/POST/PUT/DELETE /model-config` | 模型配置 CRUD | +| `GET /workspace/*` | 源文件访问(main.go BindHandler 静态服务,鉴权放行 + 路径穿越防护) | +| `POST /model-config/{id}/test` | 连通性测试 | + +--- + +## 7. RAG 问答设计 + +> 本层不再单独建目录:分块/Indexer 归入 `chunk_service.go`,Retriever/组件工厂/工作流归入 `chat_service.go`,分词归入 `common/tokenizer.go`。 + +### 7.1 组件清单 + +| 组件 | 实现 | 归属 | +|---|---|---| +| ChatModel | 自研 `OpenAIChatModel` | `chat_service.go`:OpenAI 兼容 `/chat/completions`(Generate/Stream),baseURL 可配,兼容 Ollama/DeepSeek/OpenAI 等 | +| Embedding | 自研 `OpenAIEmbedder` | `chat_service.go`:OpenAI 兼容 `/embeddings`;按库绑定的 embedding 配置创建,模型必须与索引端一致 | +| Indexer | `ChunkService.InsertAll` | `chunk_service.go`:批量向量化 + 写入 chunk + vec0 + FTS5(解析流水线非图节点,不单独实现 eino Indexer 接口) | +| Retriever | **自研 `HybridRetriever`** | `chat_service.go`:实现 `eino.Retriever` 接口,vec0 向量检索 + FTS5 关键词检索 + RRF 融合 | +| Splitter | 自研(标题感知分块) | `chunk_service.go`:标题感知 + 800字 + 100重叠 | +| Tokenizer | gse 中文分词 | `common/tokenizer.go`:写索引/检索共用 | + +### 7.2 写入路径(`ChunkService.InsertAll`) + +```go +func (s *chunkService) InsertAll(ctx context.Context, datasetId, documentId int64, + chunks []string, embedder embedding.Embedder) error +``` + +流程: +1. 数据集绑定 embedding 配置时构建 `OpenAIEmbedder`(`parse_task_service` 解析流水线中完成,无配置降级仅写 FTS5) +2. 按 `EmbedBatchSize`(16)批量调用 `Embedder.EmbedStrings` 生成向量 +3. 逐 chunk 同一事务:`kb_chunk` 自增 id → `kb_chunk_vec(chunk_id, vec_f32(?))` → gse 分词后 `kb_chunk_fts(chunk_id, dataset_id, title, content_tokens)` +4. 更新 `kb_document.chunk_count`、`status=2` + +> 解析流水线为轮询任务而非图节点,故不单独实现 eino Indexer 接口,`InsertAll` 承担等价职责。 + +### 7.3 自研 HybridRetriever(读取路径) + +```go +type HybridRetriever struct { + datasetId int64 + embedder embedding.Embedder +} + +// 实现 eino Retriever 接口 +func (h *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...retriever.Option) ([]*schema.Document, error) +``` + +流程: +1. **向量检索**:query → Embedding → `vec0` KNN 余弦 TopK(如 20) +2. **关键词检索**:query → gse 分词 → FTS5 MATCH TopK(如 20) +3. **RRF 融合**:`score = Σ 1/(60 + rank)`,取 TopK(如 10) +4. 回表 `kb_chunk` 取原文与元数据,组装 `schema.Document`,`doc.WithScore(score)` 记录得分与引用信息 +5. 支持 `retriever.WithTopK` / `WithScoreThreshold` 选项 + +```go +// RRF 融合 +type hit struct{ chunkId int64; ranks []int; score float64 } +score = Σ(1 / (60 + rank)) +``` + +### 7.4 问答工作流(chat_service.go 编排) + +``` +用户问题 + │ + ▼ +┌─────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐ +│ Hybrid │──▶│ Prompt │──▶│ ChatModel │──▶│ 流式输出 │ +│ Retriever│ │ (上下文+问题) │ │ (OpenAI兼容)│ │ (SSE) │ +└─────────┘ └──────────────┘ └────────────┘ └───────────┘ +``` + +- `chat_service.go` 中直接编排 eino 组件:`ChatService.Ask` = HybridRetriever.Retrieve → 组装引用列表 + 系统提示词 → OpenAIChatModel.Stream 流式生成(组件已实现 eino 接口,可随时迁入 graph/chain 拓扑;eino v0.9.13 的 graph 节点类型约束与"中间取引用"需求不匹配,故直接编排) +- `message_service.go`:会话解析/创建、用户消息落库、历史消息组装(最近 10 轮)、助手消息 + citations JSON 落库 +- Prompt 模板: + ``` + 你是一个本地知识库助手。请仅根据以下资料回答用户问题;若资料不足以回答,请明确说明。 + 回答引用资料时,在对应位置标注 [编号]。 + + 【资料】 + [1] 内容... + [2] 内容... + ``` +- SSE 事件顺序:`citations`(先推,含引用列表与 conversation_id)→ `delta`(增量文本)→ `done`;错误推 `error` 事件 +- 引用列表编号 [1][2] 与提示词中资料编号一一对应,随助手消息以 JSON 落库(chat_message.citations) + +### 7.5 中文分词 + +- `github.com/go-ego/gse`(纯 Go,无 CGO),初始化标准中文词典(约 35 万词) +- 写索引:`gse.Cut(text)` → 过滤停用词/单字 → 空格 join → `content_tokens` +- 查询:同样流程;额外保留原 query 子串供 trigram 兜底(可选) +- 词典可随镜像内置 `dict/zh/dict.txt` + +### 7.6 文档解析流水线 + +``` +POST /document/upload + │ 保存文件到 workspace/{datasetId}/{yyyymmdd}/{uuid}.ext + │ 插入 kb_document(status=0) + kb_parse_task(status=0) + ▼ +StartParsePoller(main.go 启动,3 秒轮询) + │ 取 status=0 任务 → 置 status=1 + │ 1. 按扩展名分发解析器(txt/md 直接读;pdf 用 pdfcpu 抽取文本; + │ docx 解 zip 读 word/document.xml 提取段落;html 用 goquery 取 body 文本) + │ 2. 分块:优先按标题/段落切(# 标题、空行、PDF 分页), + │ 超过 max_chunk_size(800字) 时按句号/换行回退切分,重叠 100 字 + │ 3. 批量 Embedding → SqliteIndexer.Store + │ 4. 更新 document.status=2、chunk_count;任务 status=2 + │ 失败 → status=3 + error_msg,前端可重试 +``` + +### 7.7 知识图谱(LLM 抽取 + 图增强检索) + +面向"数据集整体关系"类问题(如"谁与谁合作过""公司有哪些产品线"),在向量/关键词检索之外补充图谱能力。图谱**不是替代 RAG**,而是为问答注入结构化关系上下文。 + +**构建(LLM 抽取,挂在解析流水线第 3.5 步)**: + +``` +解析 → 分块 → 向量化 ──▶ 批量抽取(每 chunk 一次 LLM 调用,JSON 输出) + ▼ + {entities:[{name, type}], relations:[{head, relation, tail}]} + ▼ + upsert kg_entity(按 dataset_id+name 去重)→ 写 kg_relation(带来源 chunk_id) +``` + +- 复用 M4 的 chat 组件工厂,抽取 Prompt 要求模型只输出 JSON: + ```json + {"entities": [{"name": "张明", "type": "person"}], "relations": [{"head": "张明", "relation": "任职于", "tail": "XX科技"}]} + ``` +- 实体按 `(dataset_id, name)` 去重(同一实体多 chunk 出现只建一次,UNIQUE 约束 + ON CONFLICT upsert),关系带来源 chunk_id;删除文档时按分块 id 级联清理 +- 抽取失败不阻断流水线(记录错误,文档状态仍为完成);未配置默认对话模型时整体跳过 + +**使用(图增强检索,挂在 HybridRetriever 之后)**: + +``` +用户问题 + │ + ├─▶ 混合检索(向量+FTS5)──┐ + ├─▶ 实体链接:问题文本分词后与 kg_entity.name 精确/子串匹配,取 Top 3 命中实体 + │ └─▶ 一跳邻居:取这些实体的关系三元组(头/尾任意一端命中即取,上限 20 条) + ▼ + 组装上下文:检索片段 + 三元组列表("【知识图谱】张明 -任职于-> XX科技")→ ChatModel +``` + +- 实体链接用 `common/tokenizer.go` 分词 + 名称匹配,零模型调用;打分规则:问题含完整实体名 +5,命中 token 按长度加权,Top3 后名称长者优先 +- 三元组作为辅助上下文注入 prompt(排序在检索片段之后),增强模型对关系类问题的回答 +- `kg_entity` / `kg_relation` 两表归属 business.db,各配独立 entity/dao/service/controller 文件(分层 10 文件对齐) + +--- + +## 8. 鉴权设计(单用户 + 启动令牌) + +面向边缘设备部署,不做用户体系,只有一个"门禁"级别的访问令牌: + +### 8.1 令牌生命周期 + +``` +首次启动 + │ 读 system_config(access_token) + │ ┌─ 不存在 → 随机生成 32 位十六进制 token,写入 system_config,日志打印: + │ │ ============================================ + │ │ 访问令牌(登录用): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 + │ │ 请在登录页输入上述令牌 + │ │ ============================================ + │ └─ 已存在 → 直接打印当前 token(便于找回,重启不重新生成) + ▼ +登录页:输入 token → POST /system-config/login → 校验通过 → 签发 JWT + │ JWT claims: { role: "owner", token_fp: 指纹 },有效期 24h + ▼ +后续请求:Authorization: Bearer ,中间件校验 token_fp 与当前令牌指纹一致 +``` + +要点: + +- **指纹校验**:JWT 中携带 access_token 的 SHA-256 指纹(前 16 位 hex);鉴权中间件将 JWT 指纹与 `system_config` 中当前令牌指纹比对(带缓存),不一致即 401。因此**重新生成令牌后所有旧会话立即失效** +- **重新生成**:设置页 `POST /system-config/regenerate-token` → 生成新令牌覆盖并打印日志,前端强制跳转登录页 +- **令牌存储**:system_config 明文存储(本地单机、日志与设置页本就可见明文,且设置页需展示) +- **公开路径白名单**:`/system-config/login`、`GET /`、`GET /assets/*`(hash 路由下 SPA 只请求这两类静态路径,无鉴权绕过);`GET /workspace/*` 前缀放行(同 video-factory `/workspace/*`:浏览器下载/预览请求不带 Authorization),仅做路径穿越防护;其余路径一律校验 +- 无注册、无角色、无用户表;登录日志不落库(如需审计可在日志文件输出) + +```go +// common/auth_middleware.go(伪码) +func Auth(r *ghttp.Request) { + if isPublicPath(r.URL.Path) { r.Middleware.Next(); return } + claims, err := ParseToken(bearer(r)) + if err != nil || claims.TokenFp != currentTokenFingerprint() { + r.Response.WriteJson(401); r.Exit(); return + } + r.Middleware.Next() +} +``` + +--- + +## 9. 前端设计(Vue 3 + Vite + Element Plus) + +### 9.1 工程 + +- `ui-src/` 独立 Vite 工程:`vite.config.js` 设 `base: '/'`、`build.outDir: 'dist'` +- hash 路由(无需服务端 SPA fallback,与 video-factory 一致),路由守卫:无 JWT → 跳登录页 +- 依赖:`vue@3`、`vue-router@4`、`pinia`、`element-plus`、`axios` +- 构建产物 `ui-src/dist`,本地开发 `npm run build` 后由 Go 统一端口托管;开发期可用 Vite dev server + proxy + +### 9.2 页面 + +| 页面 | 功能 | +|---|---| +| 登录页 | 输入访问令牌登录(提示语:令牌见服务启动日志 / `docker compose logs`);无注册入口 | +| 数据集列表 | 卡片式列表、新建/删除/设置(绑定 embedding 模型) | +| 数据集详情 | 文档上传(拖拽 + 进度)、文档列表(解析状态/分块数/失败重试)、分块预览与编辑 | +| 问答页 | 左侧会话列表,右侧消息流;SSE 流式渲染,答案带引用折叠面板(点击定位到分块原文) | +| 设置页 | 模型配置 CRUD(chat/embedding)、连通性测试;**访问令牌管理**(查看/复制/重新生成,重新生成后强制重新登录) | + +### 9.3 API 封装 + +- `api/http.js`:axios 实例(baseURL `/`、token 注入、401 跳转、错误 toast) +- `api/xxx.js`:按后端模块组织,与 dto 对齐 +- 流式:`fetch` + `ReadableStream` 解析 SSE(`data: {"content":"..."}` 增量 / `data: {"citations":[...]}` 引用 / `data: [DONE]`) + +--- + +## 10. Docker 部署 + +### 10.1 Dockerfile(多阶段,参照 video-factory) + +```dockerfile +# ==================== 前端构建 ==================== +FROM node:20-alpine AS ui-builder +RUN apk add --no-cache git +WORKDIR /build-ui +COPY rag-local/ui-src/package.json rag-local/ui-src/package-lock.json ./ +RUN npm ci --registry=https://registry.npmmirror.com +COPY rag-local/ui-src/ ./ +RUN npm run build + +# ==================== 后端构建 ==================== +FROM golang:alpine AS builder +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ + && apk add --no-cache git ca-certificates tzdata +ENV TZ=Asia/Shanghai GO111MODULE=on \ + GOPROXY=https://goproxy.cn,direct \ + CGO_ENABLED=0 GOTOOLCHAIN=auto +WORKDIR /build +COPY rag-local/ . +RUN go mod download && go build -ldflags="-s -w" -o main ./main.go + +# ==================== 运行镜像 ==================== +FROM alpine:3.19 +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ + && apk add --no-cache ca-certificates tzdata +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +WORKDIR /app +COPY --from=builder /build/main . +COPY --from=builder /build/config.yml . +COPY --from=ui-builder /build-ui/dist ./ui-src/dist +RUN mkdir -p /app/data /app/workspace \ + && printf '#!/bin/sh\nfor db in business.db system.db chat.db; do\n if [ -d /app/data/$db ]; then rm -rf /app/data/$db; fi\n touch /app/data/$db 2>/dev/null || true\ndone\nexec ./main\n' > /app/entrypoint.sh \ + && chmod +x /app/entrypoint.sh +EXPOSE 8080 +ENTRYPOINT ["/app/entrypoint.sh"] +``` + +### 10.2 docker-compose.yml + +```yaml +services: + rag-local: + build: + context: .. + dockerfile: rag-local/Dockerfile + container_name: rag-local + ports: + - "8080:8080" # 统一端口:前后端一体 + volumes: + - /data/rag-local/data:/app/data # 数据库(3 个 db 文件) + - /data/rag-local/workspace:/app/workspace # 上传的文档源文件 + restart: unless-stopped + +networks: + default: + name: rag-local-network +``` + +> 数据即文件:备份 = 打包 `data`(3 个 db)+ `workspace`(源文件)两个目录;迁移 = 拷贝到新机器即可。 +> 首次启动后通过 `docker compose logs rag-local` 查看访问令牌。 + +--- + +## 11. 开发与构建命令 + +```bash +# 后端 +cd rag-local +go mod tidy +go get modernc.org/sqlite@v1.47.0 # 关键:向量扩展依赖 +go build ./... # 验证编译(见 §2.1 风险提示) +go run main.go # 启动日志中查看访问令牌 + +# 前端 +cd rag-local/ui-src +npm install +npm run build # 产物 ui-src/dist,Go 统一端口托管 +npm run dev # 开发期(Vite + proxy) + +# 部署 +docker compose up -d --build +docker compose logs rag-local # 查看访问令牌 +``` + +--- + +## 12. 实施步骤(里程碑) + +| 阶段 | 内容 | 验收标准 | +|---|---|---| +| **M1 骨架** | go mod、config.yml 三库、common 层(http/auth/base_dao/cache)、consts、system_config(令牌生成/登录)与 model_config 全链路(entity/dao/service/controller/dto)、ui-src 空壳 SPA 由 Go 托管 | 8080 端口可打开页面,启动日志打印令牌,登录页输入令牌可进入,3 个 db 文件生成 | +| **M2 文档流水线** | dataset/document/parse_task 全链路、解析器(txt/md/pdf/docx/html)、分块、任务轮询 | 上传文档 → 解析 → 分块 → 状态流转,前端可看 chunk 列表 | +| **M3 向量 + 全文** | 升级 modernc v1.47、验证 vec0、chunk_vec/chunk_fts 建表、gse 分词、SqliteIndexer/向量检索/FTS5 检索、RRF 融合 | 库内文档可被语义检索与关键词检索命中 | +| **M4 RAG 问答** ✅ | model_config 全链路、chat/embedding 组件工厂、Eino Graph 工作流、SSE 流式、conversation/message 落库、引用展示 | 问答页流式对话,回答有引用来源,历史消息可回溯 | +| **M5 知识图谱** ✅ | kg_entity/kg_relation 全链路、LLM 抽取(挂在解析流水线)、实体链接 + 一跳邻居注入 prompt | 图谱页可看实体/关系,问答能回答关系类问题 | +| **M6 打磨与部署** ✅ | 分块编辑(改后自动重向量化)、文档重新向量化、令牌查看/重新生成、模型连通性测试、设置/数据集/详情三页填充、Dockerfile + docker-compose + README | docker compose up 一键启动,全功能可用 | + +--- + +## 13. 依赖清单(go.mod) + +``` +github.com/gogf/gf/v2 v2.10.x +github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.x +modernc.org/sqlite v1.47.0+ # 显式升级,内置 sqlite-vec +github.com/cloudwego/eino # RAG 编排 +github.com/cloudwego/eino-ext # chat/openai、embedding/openai +github.com/go-ego/gse # 中文分词(纯 Go) +github.com/pdfcpu/pdfcpu # PDF 文本抽取 +github.com/PuerkitoBio/goquery # HTML 解析 +github.com/golang-jwt/jwt/v5 # JWT +``` + +--- + +## 14. 风险与备选方案 + +### 14.1 向量扩展兼容性(主要风险) + +**风险**:glebarez/go-sqlite(GoFrame 驱动链)锁定 modernc v1.23.1,MVS 升级到 v1.47.0 后可能存在 API 编译不兼容。 + +**应对**:M3 阶段第一步即验证 `go build`。若失败,按序切换: + +- **备选 A(推荐)**:自研平面扫描向量检索 —— `kb_chunk_vec` 改用普通表存向量 JSON,检索时过滤 + 内存余弦相似度排序(批量预取 + 并发分片)。10 万 chunk × 1024 维单次查询约 20–50ms,本地知识库规模完全够用。Indexer/Retriever 接口不变,仅换后端实现。 +- **备选 B**:`gosqlite.org` 模块(CGo-free,类型化 vec + FTS5 API),业务 SQL 走 GoFrame、向量/FTS 走独立连接,应用层 RRF 融合。 +- **备选 C**:LanceDB(文件型向量库,纯 Go SDK),向量独立目录存储。 + +### 14.2 中文检索质量 + +FTS5 召回依赖 gse 分词质量;专有名词(人名/产品名)可能切碎。缓解:检索时同时保留 trigram 兜底,或对命中率低的 query 降级为向量检索为主。 + +### 14.3 SQLite 写并发 + +解析任务轮询与用户操作可能并发写 business.db。缓解:WAL 模式 + 任务串行消费 + 写操作集中到 service 层(参照 video-factory 单机场景)。 + +### 14.4 切换 embedding 模型 + +不同模型的向量空间不可混用。方案:数据集绑定 embedding 配置,切换时强制"重新向量化"(`reembed` 任务重建全部向量与 FTS 索引),并清空缓存。 + +--- + +## 15. 与 video-factory 的差异点说明 + +| 项 | video-factory | rag-local | +|---|---|---| +| 数据库 | 3 个 SQLite(业务/系统/财务) | 3 个 SQLite(业务/系统/问答) | +| 向量/全文 | 无 | **SQLite 同库内嵌**(vec0 + FTS5),无额外服务 | +| AI 层 | 自研 ReAct agent(openai 直连) | **Eino** 组件化编排(Indexer/Retriever/Graph) | +| 鉴权 | 多用户 + 角色 + JWT(user/login_log 表) | **单用户访问令牌**:启动生成打印,登录换取 JWT,无用户表 | +| 前端 | HTML + 少量 Vue | 纯 Vue 3 SPA(构建产物 Go 托管) | +| 异步任务 | 视频生成轮询 | 文档解析/向量化轮询 | +| 其余 | 分层/路由/鉴权/缓存/部署模式 | 完全对齐 | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..18a6051 --- /dev/null +++ b/go.mod @@ -0,0 +1,81 @@ +module rag-local + +go 1.26.1 + +require ( + github.com/PuerkitoBio/goquery v1.12.0 + github.com/cloudwego/eino v0.9.13 + github.com/go-ego/gse v1.0.2 + github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 + github.com/gogf/gf/v2 v2.10.2 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/pdfcpu/pdfcpu v0.14.0 + modernc.org/sqlite v1.47.0 +) + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eino-contrib/jsonschema v1.0.3 // indirect + github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/goph/emperror v0.17.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grokify/html-strip-tags-go v0.1.0 // indirect + github.com/hhrutter/tiff v1.0.6 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/nikolalohinski/gonja v1.5.3 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.0.9 // indirect + github.com/olekukonko/tablewriter v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.0.9 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/vcaesar/cedar v0.30.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yargevad/filepathx v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/arch v0.11.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/image v0.44.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..63af4be --- /dev/null +++ b/go.sum @@ -0,0 +1,332 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= +github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= +github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cloudwego/eino v0.9.13 h1:iD/ETS+lxnNp1VeNPqWVGPWdND6Dbf4LyINbLUlDRcM= +github.com/cloudwego/eino v0.9.13/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= +github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= +github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= +github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-ego/gse v1.0.2 h1:+27lYFPhQEhA9igtdOsJPRKYL/k3TwYsxBF5jr6KFv4= +github.com/go-ego/gse v1.0.2/go.mod h1:Fy35G+q7VV7Et1zIKO8o/sW1kkugV3znXap/lF/11zc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 h1:KLS68SWS2W749x7e+eCCOO3UD2Sbw+bIbLEPR8o1FXw= +github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2/go.mod h1:uLcsu73PfpyhRc0Jq0gGAWQjN1tyGU9iBRrYgt/lu7g= +github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8= +github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= +github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= +github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hhrutter/tiff v1.0.6 h1:p5I4Oi20jit3uWIBBaAoMDqrKztw/1JQCQC2TgqK1qU= +github.com/hhrutter/tiff v1.0.6/go.mod h1:9+PDcnTBkMrJ8fWXkN1ZPv5ZNcKsFuTGVQU3ysaQbco= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= +github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= +github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= +github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pdfcpu/pdfcpu v0.14.0 h1:KRC7JMWiFZD4uIjYwTYR9txG9vYGQj+NdjTFkfUKd14= +github.com/pdfcpu/pdfcpu v0.14.0/go.mod h1:NhG6T7b2EEdToXGD5hj8rmXBWSLCjgljCk5c0H6U9x8= +github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= +github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/vcaesar/cedar v0.30.0 h1:9fSDpM7FTjjUdPiBUUa0MWYMRGSEcqgFXvppZcZ4d7Y= +github.com/vcaesar/cedar v0.30.0/go.mod h1:lyuGvALuZZDPNXwpzv/9LyxW+8Y6faN7zauFezNsnik= +github.com/vcaesar/tt v0.20.1 h1:D/jUeeVCNbq3ad8M7hhtB3J9x5RZ6I1n1eZ0BJp7M+4= +github.com/vcaesar/tt v0.20.1/go.mod h1:cH2+AwGAJm19Wa6xvEa+0r+sXDJBT0QgNQey6mwqLeU= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= +github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= +github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= +golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/kb/consts/consts.go b/kb/consts/consts.go new file mode 100644 index 0000000..0d56e1e --- /dev/null +++ b/kb/consts/consts.go @@ -0,0 +1,17 @@ +package consts + +// 向量与检索参数 +const ( + DefaultEmbeddingDim = 1024 // embedding 默认维度(未配置时) + VectorTopK = 20 // 向量检索召回数 + FtsTopK = 20 // 全文检索召回数 + HybridTopK = 10 // 混合检索融合后返回数 + RrfK = 60 // RRF 融合常数 + + MaxChunkSize = 800 // 分块最大字数 + ChunkOverlap = 100 // 分块重叠字数 + + ParsePollIntervalSeconds = 3 // 解析任务轮询间隔 + + EmbedBatchSize = 16 // 单次向量化请求的文本批量 +) diff --git a/kb/consts/content_type.go b/kb/consts/content_type.go new file mode 100644 index 0000000..98ba1e2 --- /dev/null +++ b/kb/consts/content_type.go @@ -0,0 +1,14 @@ +package consts + +// 模型类型 +const ( + ModelTypeChat = "chat" + ModelTypeEmbedding = "embedding" +) + +// system_config 预置键 +const ( + CfgKeyAccessToken = "access_token" + CfgKeyDefaultChatModel = "default_chat_model" + CfgKeyDefaultDataset = "default_dataset" +) diff --git a/kb/consts/status.go b/kb/consts/status.go new file mode 100644 index 0000000..e359873 --- /dev/null +++ b/kb/consts/status.go @@ -0,0 +1,30 @@ +package consts + +// 文档状态 +const ( + DocumentStatusPending = 0 // 待处理 + DocumentStatusParsing = 1 // 处理中 + DocumentStatusDone = 2 // 完成 + DocumentStatusFailed = 3 // 失败 +) + +// 解析任务状态 +const ( + TaskStatusPending = 0 // 待处理 + TaskStatusRunning = 1 // 处理中 + TaskStatusDone = 2 // 完成 + TaskStatusFailed = 3 // 失败 +) + +// 任务类型 +const ( + TaskTypeParse = "parse" // 解析+向量化 + TaskTypeReembed = "reembed" // 重新向量化(切换 embedding 模型后) +) + +// 消息角色 +const ( + MsgRoleUser = "user" + MsgRoleAssistant = "assistant" + MsgRoleSystem = "system" +) diff --git a/kb/consts/table_name.go b/kb/consts/table_name.go new file mode 100644 index 0000000..b8485ea --- /dev/null +++ b/kb/consts/table_name.go @@ -0,0 +1,23 @@ +package consts + +const ( + TableNameSystemConfig = "system_config" + TableNameModelConfig = "model_config" + TableNameDataset = "kb_dataset" + TableNameDocument = "kb_document" + TableNameChunk = "kb_chunk" + TableNameChunkVec = "kb_chunk_vec" + TableNameChunkFts = "kb_chunk_fts" + TableNameParseTask = "kb_parse_task" + TableNameConversation = "chat_conversation" + TableNameMessage = "chat_message" + TableNameKgEntity = "kg_entity" + TableNameKgRelation = "kg_relation" +) + +// 数据库组:默认组(default)=business.db、system=system.db、chat=chat.db +const ( + DbGroupDefault = "" // 默认组,对应 business.db + DbGroupSystem = "system" // system.db + DbGroupChat = "chat" // chat.db +) diff --git a/kb/controller/chunk_controller.go b/kb/controller/chunk_controller.go new file mode 100644 index 0000000..9867bf0 --- /dev/null +++ b/kb/controller/chunk_controller.go @@ -0,0 +1,32 @@ +package controller + +import ( + "context" + + "rag-local/kb/model/dto" + "rag-local/kb/service" +) + +type chunk struct{} + +var Chunk = &chunk{} + +func (c *chunk) List(ctx context.Context, req *dto.ListChunkReq) (*dto.ListChunkRes, error) { + list, total, err := service.ChunkService.List(ctx, req.DocumentId, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &dto.ListChunkRes{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +func (c *chunk) Update(ctx context.Context, req *dto.UpdateChunkReq) (*dto.UpdateChunkRes, error) { + if err := service.ChunkService.Update(ctx, req.Id, req.Content); err != nil { + return nil, err + } + return &dto.UpdateChunkRes{}, nil +} diff --git a/kb/controller/conversation_controller.go b/kb/controller/conversation_controller.go new file mode 100644 index 0000000..302e709 --- /dev/null +++ b/kb/controller/conversation_controller.go @@ -0,0 +1,40 @@ +package controller + +import ( + "context" + + "rag-local/kb/model/dto" + "rag-local/kb/model/entity" + "rag-local/kb/service" +) + +type conversation struct{} + +var Conversation = &conversation{} + +func (c *conversation) List(ctx context.Context, req *dto.ListConversationReq) (*dto.ListConversationRes, error) { + list, err := service.ConversationService.List(ctx) + if err != nil { + return nil, err + } + return &dto.ListConversationRes{List: list}, nil +} + +func (c *conversation) Save(ctx context.Context, req *dto.SaveConversationReq) (*dto.SaveConversationRes, error) { + id, err := service.ConversationService.Save(ctx, &entity.Conversation{ + Id: req.Id, + DatasetId: req.DatasetId, + Title: req.Title, + }) + if err != nil { + return nil, err + } + return &dto.SaveConversationRes{Id: id}, nil +} + +func (c *conversation) Delete(ctx context.Context, req *dto.DeleteConversationReq) (*dto.DeleteConversationRes, error) { + if err := service.ConversationService.Delete(ctx, req.Id); err != nil { + return nil, err + } + return &dto.DeleteConversationRes{}, nil +} diff --git a/kb/controller/dataset_controller.go b/kb/controller/dataset_controller.go new file mode 100644 index 0000000..d6da4fe --- /dev/null +++ b/kb/controller/dataset_controller.go @@ -0,0 +1,42 @@ +package controller + +import ( + "context" + + "rag-local/kb/model/dto" + "rag-local/kb/model/entity" + "rag-local/kb/service" +) + +type dataset struct{} + +var Dataset = &dataset{} + +func (c *dataset) List(ctx context.Context, _ *dto.ListDatasetReq) (*dto.ListDatasetRes, error) { + list, err := service.DatasetService.List(ctx) + if err != nil { + return nil, err + } + return &dto.ListDatasetRes{List: list}, nil +} + +func (c *dataset) Save(ctx context.Context, req *dto.SaveDatasetReq) (*dto.SaveDatasetRes, error) { + id, err := service.DatasetService.Save(ctx, &entity.Dataset{ + Id: req.Id, + Name: req.Name, + Description: req.Description, + EmbeddingCfgId: req.EmbeddingCfgId, + Status: 1, + }) + if err != nil { + return nil, err + } + return &dto.SaveDatasetRes{Id: id}, nil +} + +func (c *dataset) Delete(ctx context.Context, req *dto.DeleteDatasetReq) (*dto.DeleteDatasetRes, error) { + if err := service.DatasetService.Delete(ctx, req.Id); err != nil { + return nil, err + } + return &dto.DeleteDatasetRes{}, nil +} diff --git a/kb/controller/document_controller.go b/kb/controller/document_controller.go new file mode 100644 index 0000000..62eb1bf --- /dev/null +++ b/kb/controller/document_controller.go @@ -0,0 +1,62 @@ +package controller + +import ( + "context" + "io" + + "rag-local/kb/model/dto" + "rag-local/kb/service" + + "github.com/gogf/gf/v2/errors/gerror" +) + +type document struct{} + +var Document = &document{} + +func (c *document) Upload(ctx context.Context, req *dto.UploadDocumentReq) (*dto.UploadDocumentRes, error) { + if req.File == nil { + return nil, gerror.New("请选择文件") + } + f, err := req.File.Open() + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + data, err := io.ReadAll(f) + if err != nil { + return nil, err + } + doc, err := service.DocumentService.Upload(ctx, req.DatasetId, req.File.Filename, data) + if err != nil { + return nil, err + } + return &dto.UploadDocumentRes{Id: doc.Id}, nil +} + +func (c *document) List(ctx context.Context, req *dto.ListDocumentReq) (*dto.ListDocumentRes, error) { + list, total, err := service.DocumentService.List(ctx, req.DatasetId, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &dto.ListDocumentRes{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +func (c *document) Delete(ctx context.Context, req *dto.DeleteDocumentReq) (*dto.DeleteDocumentRes, error) { + if err := service.DocumentService.Delete(ctx, req.Id); err != nil { + return nil, err + } + return &dto.DeleteDocumentRes{}, nil +} + +func (c *document) Reembed(ctx context.Context, req *dto.ReembedDocumentReq) (*dto.ReembedDocumentRes, error) { + if err := service.DocumentService.Reembed(ctx, req.Id); err != nil { + return nil, err + } + return &dto.ReembedDocumentRes{}, nil +} diff --git a/kb/controller/kg_entity_controller.go b/kb/controller/kg_entity_controller.go new file mode 100644 index 0000000..cfabd9b --- /dev/null +++ b/kb/controller/kg_entity_controller.go @@ -0,0 +1,25 @@ +package controller + +import ( + "context" + + "rag-local/kb/model/dto" + "rag-local/kb/service" +) + +type kgEntity struct{} + +var KgEntity = &kgEntity{} + +func (c *kgEntity) List(ctx context.Context, req *dto.ListKgEntityReq) (*dto.ListKgEntityRes, error) { + list, total, err := service.KgEntityService.List(ctx, req.DatasetId, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &dto.ListKgEntityRes{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} diff --git a/kb/controller/kg_relation_controller.go b/kb/controller/kg_relation_controller.go new file mode 100644 index 0000000..94893f2 --- /dev/null +++ b/kb/controller/kg_relation_controller.go @@ -0,0 +1,25 @@ +package controller + +import ( + "context" + + "rag-local/kb/model/dto" + "rag-local/kb/service" +) + +type kgRelation struct{} + +var KgRelation = &kgRelation{} + +func (c *kgRelation) List(ctx context.Context, req *dto.ListKgRelationReq) (*dto.ListKgRelationRes, error) { + list, total, err := service.KgRelationService.List(ctx, req.DatasetId, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &dto.ListKgRelationRes{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} diff --git a/kb/controller/message_controller.go b/kb/controller/message_controller.go new file mode 100644 index 0000000..4ce09cb --- /dev/null +++ b/kb/controller/message_controller.go @@ -0,0 +1,78 @@ +package controller + +import ( + "context" + "encoding/json" + "time" + + "rag-local/kb/model/domain" + "rag-local/kb/model/dto" + "rag-local/kb/service" + + "github.com/gogf/gf/v2/frame/g" +) + +type message struct{} + +var Message = &message{} + +func (c *message) List(ctx context.Context, req *dto.ListMessageReq) (*dto.ListMessageRes, error) { + list, err := service.MessageService.List(ctx, req.ConversationId) + if err != nil { + return nil, err + } + return &dto.ListMessageRes{List: list}, nil +} + +// Chat RAG 问答 SSE 流式:citations → delta* → done / error +func (c *message) Chat(ctx context.Context, req *dto.ChatReq) (*dto.ChatRes, error) { + r := g.RequestFromCtx(ctx) + r.Response.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + r.Response.Header().Set("Cache-Control", "no-cache") + r.Response.Header().Set("X-Accel-Buffering", "no") + r.Response.Header().Set("Connection", "keep-alive") + + send := func(event string, data any) bool { + buf, err := json.Marshal(data) + if err != nil { + return false + } + if event != "" { + r.Response.Write("event: " + event + "\n") + } + r.Response.Write("data: " + string(buf) + "\n\n") + r.Response.Flush() + return true + } + + // 心跳:长回答期间保持连接,避免网关断流 + stopHeartbeat := make(chan struct{}) + defer close(stopHeartbeat) + go func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + select { + case <-stopHeartbeat: + return + case <-ticker.C: + r.Response.Write(": ping\n\n") + r.Response.Flush() + } + } + }() + + _, _, _, err := service.MessageService.Chat(ctx, req.ConversationId, req.DatasetId, req.Question, + func(citations []domain.Citation, conversationId int64) { + send("citations", map[string]any{"conversation_id": conversationId, "citations": citations}) + }, + func(delta string) { + send("delta", map[string]string{"content": delta}) + }) + if err != nil { + send("error", map[string]string{"message": err.Error()}) + return nil, nil + } + send("done", map[string]string{"status": "ok"}) + return nil, nil +} diff --git a/kb/controller/model_config_controller.go b/kb/controller/model_config_controller.go new file mode 100644 index 0000000..5efd8d8 --- /dev/null +++ b/kb/controller/model_config_controller.go @@ -0,0 +1,49 @@ +package controller + +import ( + "context" + + "rag-local/kb/model/dto" + "rag-local/kb/model/entity" + "rag-local/kb/service" +) + +type modelConfig struct{} + +var ModelConfig = new(modelConfig) + +func (c *modelConfig) List(ctx context.Context, req *dto.ListModelConfigReq) (res *dto.ListModelConfigRes, err error) { + list, err := service.ModelConfigService.List(ctx, req.ModelType) + if err != nil { + return nil, err + } + return &dto.ListModelConfigRes{List: list}, nil +} + +func (c *modelConfig) Save(ctx context.Context, req *dto.SaveModelConfigReq) (res *dto.SaveModelConfigRes, err error) { + id, err := service.ModelConfigService.Save(ctx, &entity.ModelConfig{ + Id: req.Id, + Name: req.Name, + ModelType: req.ModelType, + ModelName: req.ModelName, + EndpointUrl: req.EndpointUrl, + ApiKey: req.ApiKey, + Dimension: req.Dimension, + Extra: req.Extra, + }) + if err != nil { + return nil, err + } + return &dto.SaveModelConfigRes{Id: id}, nil +} + +func (c *modelConfig) Delete(ctx context.Context, req *dto.DeleteModelConfigReq) (res *dto.DeleteModelConfigRes, err error) { + return nil, service.ModelConfigService.Delete(ctx, req.Id) +} + +func (c *modelConfig) Test(ctx context.Context, req *dto.TestModelConfigReq) (res *dto.TestModelConfigRes, err error) { + if err := service.ModelConfigService.Test(ctx, req.Id); err != nil { + return &dto.TestModelConfigRes{Ok: false, Msg: err.Error()}, nil + } + return &dto.TestModelConfigRes{Ok: true, Msg: "连接正常"}, nil +} diff --git a/kb/controller/parse_task_controller.go b/kb/controller/parse_task_controller.go new file mode 100644 index 0000000..ed05b86 --- /dev/null +++ b/kb/controller/parse_task_controller.go @@ -0,0 +1,32 @@ +package controller + +import ( + "context" + + "rag-local/kb/model/dto" + "rag-local/kb/service" +) + +type parseTask struct{} + +var ParseTask = &parseTask{} + +func (c *parseTask) List(ctx context.Context, req *dto.ListParseTaskReq) (*dto.ListParseTaskRes, error) { + list, total, err := service.ParseTaskService.List(ctx, req.Page, req.PageSize) + if err != nil { + return nil, err + } + return &dto.ListParseTaskRes{ + List: list, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +func (c *parseTask) Retry(ctx context.Context, req *dto.RetryParseTaskReq) (*dto.RetryParseTaskRes, error) { + if err := service.ParseTaskService.Retry(ctx, req.Id); err != nil { + return nil, err + } + return &dto.RetryParseTaskRes{}, nil +} diff --git a/kb/controller/system_config_controller.go b/kb/controller/system_config_controller.go new file mode 100644 index 0000000..d3aaac2 --- /dev/null +++ b/kb/controller/system_config_controller.go @@ -0,0 +1,53 @@ +package controller + +import ( + "context" + "fmt" + + "rag-local/kb/model/dto" + "rag-local/kb/service" +) + +type systemConfig struct{} + +var SystemConfig = new(systemConfig) + +func (c *systemConfig) Login(ctx context.Context, req *dto.LoginReq) (res *dto.LoginRes, err error) { + token, err := service.SystemConfigService.Login(ctx, req.Token) + if err != nil { + return nil, err + } + return &dto.LoginRes{Token: token}, nil +} + +func (c *systemConfig) Get(ctx context.Context, req *dto.GetSystemConfigReq) (res *dto.GetSystemConfigRes, err error) { + chatModel, dataset, err := service.SystemConfigService.GetSettings(ctx) + if err != nil { + return nil, err + } + return &dto.GetSystemConfigRes{DefaultChatModel: chatModel, DefaultDataset: dataset}, nil +} + +func (c *systemConfig) Update(ctx context.Context, req *dto.UpdateSystemConfigReq) (res *dto.UpdateSystemConfigRes, err error) { + return nil, service.SystemConfigService.UpdateSettings(ctx, req.DefaultChatModel, req.DefaultDataset) +} + +func (c *systemConfig) GetToken(ctx context.Context, req *dto.GetTokenReq) (*dto.GetTokenRes, error) { + token, err := service.SystemConfigService.GetToken(ctx) + if err != nil { + return nil, err + } + return &dto.GetTokenRes{Token: token}, nil +} + +func (c *systemConfig) RegenerateToken(ctx context.Context, req *dto.RegenerateTokenReq) (res *dto.RegenerateTokenRes, err error) { + token, err := service.SystemConfigService.RegenerateToken(ctx) + if err != nil { + return nil, err + } + fmt.Printf("\n============================================\n") + fmt.Printf("访问令牌(登录用)已更新: %s\n", token) + fmt.Printf("旧令牌已失效,请在登录页重新输入\n") + fmt.Printf("============================================\n\n") + return &dto.RegenerateTokenRes{Token: token}, nil +} diff --git a/kb/dao/chunk_dao.go b/kb/dao/chunk_dao.go new file mode 100644 index 0000000..4bf20f1 --- /dev/null +++ b/kb/dao/chunk_dao.go @@ -0,0 +1,244 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + "strconv" + "strings" + + _ "modernc.org/sqlite/vec" + + "rag-local/common" + "rag-local/kb/consts" + "rag-local/kb/model/domain" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/text/gstr" +) + +var Chunk = &chunkDao{} + +type chunkDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameChunk+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dataset_id INTEGER NOT NULL DEFAULT 0, + document_id INTEGER NOT NULL DEFAULT 0, + seq INTEGER NOT NULL DEFAULT 0, + content TEXT NOT NULL DEFAULT '', + meta TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_chunk table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_chunk_document ON "+consts.TableNameChunk+"(document_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kb_chunk_document failed: %v", err) + } + // 向量虚拟表(维度取配置 vector.dim,切换维度需删表重建) + dim := g.Cfg().MustGet(ctx, "vector.dim", consts.DefaultEmbeddingDim).Int() + if dim < 1 { + dim = consts.DefaultEmbeddingDim + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE VIRTUAL TABLE IF NOT EXISTS `+consts.TableNameChunkVec+ + ` USING vec0(chunk_id INTEGER PRIMARY KEY, embedding float[`+strconv.Itoa(dim)+`])`); err != nil { + g.Log().Warningf(ctx, "create vec0 table failed: %v", err) + } + // 全文索引虚拟表(默认 unicode61 tokenizer;中文分词在应用层完成,content_tokens 存分词后空格连接文本) + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE VIRTUAL TABLE IF NOT EXISTS `+consts.TableNameChunkFts+ + ` USING fts5(chunk_id UNINDEXED, dataset_id UNINDEXED, title, content_tokens)`); err != nil { + g.Log().Warningf(ctx, "create fts5 table failed: %v", err) + } +} + +func (d *chunkDao) GetOne(ctx context.Context, id int64) (*entity.Chunk, error) { + var m entity.Chunk + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameChunk).Ctx(ctx).Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *chunkDao) ListByDocument(ctx context.Context, documentId int64, page, pageSize int) ([]*entity.Chunk, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + total, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameChunk).Ctx(ctx).Where("document_id", documentId).Count() + if err != nil { + return nil, 0, err + } + var list []*entity.Chunk + err = g.DB(consts.DbGroupDefault).Model(consts.TableNameChunk).Ctx(ctx). + Where("document_id", documentId).Page(page, pageSize).OrderAsc("seq").Scan(&list) + return list, total, err +} + +// InsertWithVec 事务内写入 chunk + 向量 + 全文索引,返回 chunk id +func (d *chunkDao) InsertWithVec(ctx context.Context, datasetId, documentId int64, seq int, content, meta, title string, vecJson string, dim int) (int64, error) { + tx, err := g.DB(consts.DbGroupDefault).Begin(ctx) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + + r, err := tx.Model(consts.TableNameChunk).Ctx(ctx).Data(g.Map{ + "dataset_id": datasetId, + "document_id": documentId, + "seq": seq, + "content": content, + "meta": meta, + "created_at": gtime.Now().Format("Y-m-d H:i:s"), + }).Insert() + if err != nil { + return 0, err + } + chunkId, _ := r.LastInsertId() + if chunkId == 0 { + return 0, gerror.New("chunk insert failed") + } + if vecJson != "" { + if _, err := tx.Exec("INSERT INTO "+consts.TableNameChunkVec+" (chunk_id, embedding) VALUES (?, vec_f32(?))", chunkId, vecJson); err != nil { + return 0, err + } + } + if _, err := tx.Exec("INSERT INTO "+consts.TableNameChunkFts+" (chunk_id, dataset_id, title, content_tokens) VALUES (?, ?, ?, ?)", + chunkId, datasetId, title, common.Tokenize(content)); err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return chunkId, nil +} + +// DeleteByDocument 事务内删除文档全部分块(chunk + 向量 + 全文索引) +func (d *chunkDao) DeleteByDocument(ctx context.Context, documentId int64) error { + tx, err := g.DB(consts.DbGroupDefault).Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + ids := tx.Model(consts.TableNameChunk).Ctx(ctx).Fields("id").Where("document_id", documentId) + r, err := ids.Array() + if err != nil { + return err + } + chunkIds := make([]int64, 0, len(r)) + for _, v := range r { + chunkIds = append(chunkIds, v.Int64()) + } + if len(chunkIds) > 0 { + placeholders := make([]string, 0, len(chunkIds)) + args := make([]interface{}, 0, len(chunkIds)) + for _, id := range chunkIds { + placeholders = append(placeholders, "?") + args = append(args, id) + } + in := gstr.Join(placeholders, ",") + if _, err := tx.Exec("DELETE FROM "+consts.TableNameChunkVec+" WHERE chunk_id IN ("+in+")", args...); err != nil { + return err + } + if _, err := tx.Exec("DELETE FROM "+consts.TableNameChunkFts+" WHERE chunk_id IN ("+in+")", args...); err != nil { + return err + } + } + if _, err := tx.Model(consts.TableNameChunk).Ctx(ctx).Where("document_id", documentId).Delete(); err != nil { + return err + } + return tx.Commit() +} + +func (d *chunkDao) UpdateContent(ctx context.Context, id int64, content, vecJson, tokens string) error { + tx, err := g.DB(consts.DbGroupDefault).Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Model(consts.TableNameChunk).Ctx(ctx).Data(g.Map{"content": content}).Where("id", id).Update(); err != nil { + return err + } + if vecJson != "" { + if _, err := tx.Exec("UPDATE "+consts.TableNameChunkVec+" SET embedding = vec_f32(?) WHERE chunk_id = ?", vecJson, id); err != nil { + return err + } + } + if _, err := tx.Exec("UPDATE "+consts.TableNameChunkFts+" SET content_tokens = ? WHERE chunk_id = ?", tokens, id); err != nil { + return err + } + return tx.Commit() +} + +// UpdateVec 仅更新向量(重新向量化,不动文本与 FTS) +func (d *chunkDao) UpdateVec(ctx context.Context, id int64, vecJson string) error { + if vecJson == "" { + return nil + } + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, + "UPDATE "+consts.TableNameChunkVec+" SET embedding = vec_f32(?) WHERE chunk_id = ?", vecJson, id) + return err +} + +func (d *chunkDao) CountByDocument(ctx context.Context, documentId int64) (int, error) { + return g.DB(consts.DbGroupDefault).Model(consts.TableNameChunk).Ctx(ctx).Where("document_id", documentId).Count() +} + +// VecSearch 向量 KNN 检索:vec0 取最近 topK*4 后按数据集过滤(vec0 无 dataset 列) +func (d *chunkDao) VecSearch(ctx context.Context, datasetId int64, vecJson string, topK int) ([]domain.VecHit, error) { + r, err := g.DB(consts.DbGroupDefault).Ctx(ctx).Raw( + `SELECT v.chunk_id, v.distance FROM ( + SELECT chunk_id, distance FROM `+consts.TableNameChunkVec+` + WHERE embedding MATCH ? ORDER BY distance LIMIT ? + ) v INNER JOIN `+consts.TableNameChunk+` c ON c.id = v.chunk_id + WHERE c.dataset_id = ? ORDER BY v.distance LIMIT ?`, + vecJson, topK*4, datasetId, topK, + ).All() + if err != nil { + return nil, err + } + hits := make([]domain.VecHit, 0, len(r)) + for _, row := range r { + hits = append(hits, domain.VecHit{ + ChunkId: row["chunk_id"].Int64(), + Distance: row["distance"].Float64(), + }) + } + return hits, nil +} + +// FtsSearch 全文检索(BM25):中文分词已在应用层完成,query 为空格连接的引号词串 +func (d *chunkDao) FtsSearch(ctx context.Context, datasetId int64, query string, topK int) ([]domain.FtsHit, error) { + if strings.TrimSpace(query) == "" { + return nil, nil + } + r, err := g.DB(consts.DbGroupDefault).Ctx(ctx).Raw( + `SELECT chunk_id, bm25(`+consts.TableNameChunkFts+`) AS score FROM `+consts.TableNameChunkFts+ + ` WHERE `+consts.TableNameChunkFts+` MATCH ? AND dataset_id = ? ORDER BY score LIMIT ?`, + query, datasetId, topK, + ).All() + if err != nil { + return nil, err + } + hits := make([]domain.FtsHit, 0, len(r)) + for _, row := range r { + hits = append(hits, domain.FtsHit{ + ChunkId: row["chunk_id"].Int64(), + Score: row["score"].Float64(), + }) + } + return hits, nil +} diff --git a/kb/dao/conversation_dao.go b/kb/dao/conversation_dao.go new file mode 100644 index 0000000..ca11ed7 --- /dev/null +++ b/kb/dao/conversation_dao.go @@ -0,0 +1,94 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var Conversation = &conversationDao{} + +type conversationDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupChat).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameConversation+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dataset_id INTEGER NOT NULL DEFAULT 0, + title TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create chat_conversation table failed: %v", err) + } +} + +func (d *conversationDao) GetOne(ctx context.Context, id int64) (*entity.Conversation, error) { + var m entity.Conversation + err := g.DB(consts.DbGroupChat).Model(consts.TableNameConversation).Ctx(ctx).Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *conversationDao) List(ctx context.Context) ([]*entity.Conversation, error) { + var list []*entity.Conversation + err := g.DB(consts.DbGroupChat).Model(consts.TableNameConversation).Ctx(ctx).OrderDesc("id").Scan(&list) + return list, err +} + +func (d *conversationDao) Insert(ctx context.Context, datasetId int64, title string) (int64, error) { + now := gtime.Now().Format("Y-m-d H:i:s") + r, err := g.DB(consts.DbGroupChat).Model(consts.TableNameConversation).Ctx(ctx).Data(g.Map{ + "dataset_id": datasetId, + "title": title, + "created_at": now, + "updated_at": now, + }).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *conversationDao) UpdateTitle(ctx context.Context, id int64, title string) error { + _, err := g.DB(consts.DbGroupChat).Model(consts.TableNameConversation).Ctx(ctx).Data(g.Map{ + "title": title, + "updated_at": gtime.Now().Format("Y-m-d H:i:s"), + }).Where("id", id).Update() + return err +} + +func (d *conversationDao) UpdateDataset(ctx context.Context, id, datasetId int64) error { + _, err := g.DB(consts.DbGroupChat).Model(consts.TableNameConversation).Ctx(ctx).Data(g.Map{ + "dataset_id": datasetId, + "updated_at": gtime.Now().Format("Y-m-d H:i:s"), + }).Where("id", id).Update() + return err +} + +func (d *conversationDao) Delete(ctx context.Context, id int64) error { + tx, err := g.DB(consts.DbGroupChat).Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Model(consts.TableNameMessage).Ctx(ctx).Where("conversation_id", id).Delete(); err != nil { + return err + } + if _, err := tx.Model(consts.TableNameConversation).Ctx(ctx).Where("id", id).Delete(); err != nil { + return err + } + return tx.Commit() +} diff --git a/kb/dao/dataset_dao.go b/kb/dao/dataset_dao.go new file mode 100644 index 0000000..07aa76f --- /dev/null +++ b/kb/dao/dataset_dao.go @@ -0,0 +1,95 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var Dataset = &datasetDao{} + +type datasetDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameDataset+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + embedding_cfg_id INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_dataset table failed: %v", err) + } +} + +func (d *datasetDao) GetOne(ctx context.Context, id int64) (*entity.Dataset, error) { + var m entity.Dataset + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDataset).Ctx(ctx).Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *datasetDao) List(ctx context.Context) ([]*entity.Dataset, error) { + var list []*entity.Dataset + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDataset).Ctx(ctx).OrderAsc("id").Scan(&list) + return list, err +} + +func (d *datasetDao) Insert(ctx context.Context, data *entity.Dataset) (int64, error) { + now := gtime.Now().Format("Y-m-d H:i:s") + r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDataset).Ctx(ctx).Data(g.Map{ + "name": data.Name, + "description": data.Description, + "embedding_cfg_id": data.EmbeddingCfgId, + "status": data.Status, + "created_at": now, + "updated_at": now, + }).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *datasetDao) Update(ctx context.Context, data *entity.Dataset) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDataset).Ctx(ctx).Data(g.Map{ + "name": data.Name, + "description": data.Description, + "embedding_cfg_id": data.EmbeddingCfgId, + "status": data.Status, + "updated_at": gtime.Now().Format("Y-m-d H:i:s"), + }).Where("id", data.Id).Update() + return err +} + +func (d *datasetDao) Delete(ctx context.Context, id int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDataset).Ctx(ctx).Where("id", id).Delete() + return err +} + +func (d *datasetDao) GetEmbeddingCfgId(ctx context.Context, id int64) (int64, error) { + r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDataset).Ctx(ctx). + Fields("embedding_cfg_id").Where("id", id).One() + if err != nil { + return 0, err + } + if r == nil { + return 0, nil + } + return r["embedding_cfg_id"].Int64(), nil +} diff --git a/kb/dao/document_dao.go b/kb/dao/document_dao.go new file mode 100644 index 0000000..f879529 --- /dev/null +++ b/kb/dao/document_dao.go @@ -0,0 +1,98 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var Document = &documentDao{} + +type documentDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameDocument+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dataset_id INTEGER NOT NULL DEFAULT 0, + filename TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + file_size INTEGER NOT NULL DEFAULT 0, + file_type TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 0, + chunk_count INTEGER NOT NULL DEFAULT 0, + error_msg TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_document table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_document_dataset ON "+consts.TableNameDocument+"(dataset_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kb_document_dataset failed: %v", err) + } +} + +func (d *documentDao) GetOne(ctx context.Context, id int64) (*entity.Document, error) { + var m entity.Document + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDocument).Ctx(ctx).Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *documentDao) List(ctx context.Context, datasetId int64, page, pageSize int) ([]*entity.Document, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + total, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDocument).Ctx(ctx).Where("dataset_id", datasetId).Count() + if err != nil { + return nil, 0, err + } + var list []*entity.Document + err = g.DB(consts.DbGroupDefault).Model(consts.TableNameDocument).Ctx(ctx). + Where("dataset_id", datasetId).Page(page, pageSize).OrderDesc("id").Scan(&list) + return list, total, err +} + +func (d *documentDao) Insert(ctx context.Context, data *entity.Document) (int64, error) { + now := gtime.Now().Format("Y-m-d H:i:s") + r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDocument).Ctx(ctx).Data(g.Map{ + "dataset_id": data.DatasetId, + "filename": data.Filename, + "file_path": data.FilePath, + "file_size": data.FileSize, + "file_type": data.FileType, + "status": data.Status, + "created_at": now, + "updated_at": now, + }).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *documentDao) UpdateFields(ctx context.Context, id int64, data g.Map) error { + data["updated_at"] = gtime.Now().Format("Y-m-d H:i:s") + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDocument).Ctx(ctx).Data(data).Where("id", id).Update() + return err +} + +func (d *documentDao) Delete(ctx context.Context, id int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDocument).Ctx(ctx).Where("id", id).Delete() + return err +} diff --git a/kb/dao/kg_entity_dao.go b/kb/dao/kg_entity_dao.go new file mode 100644 index 0000000..ff2b156 --- /dev/null +++ b/kb/dao/kg_entity_dao.go @@ -0,0 +1,117 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + "strings" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var KgEntity = &kgEntityDao{} + +type kgEntityDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameKgEntity+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dataset_id INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + entity_type TEXT NOT NULL DEFAULT '', + chunk_id INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')), + UNIQUE(dataset_id, name) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kg_entity table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kg_entity_dataset ON "+consts.TableNameKgEntity+"(dataset_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kg_entity_dataset failed: %v", err) + } +} + +func (d *kgEntityDao) GetOne(ctx context.Context, id int64) (*entity.KgEntity, error) { + var m entity.KgEntity + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgEntity).Ctx(ctx).Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *kgEntityDao) List(ctx context.Context, datasetId int64, page, pageSize int) ([]*entity.KgEntity, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + m := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgEntity).Ctx(ctx) + if datasetId > 0 { + m = m.Where("dataset_id", datasetId) + } + total, err := m.Count() + if err != nil { + return nil, 0, err + } + var list []*entity.KgEntity + err = m.Page(page, pageSize).OrderDesc("id").Scan(&list) + return list, total, err +} + +// ListNames 数据集全部实体名(实体链接在内存中打分,本地库规模可控) +func (d *kgEntityDao) ListNames(ctx context.Context, datasetId int64) ([]string, error) { + var list []*entity.KgEntity + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgEntity).Ctx(ctx). + Fields("name").Where("dataset_id", datasetId).Scan(&list) + if err != nil { + return nil, err + } + names := make([]string, 0, len(list)) + for _, e := range list { + names = append(names, e.Name) + } + return names, nil +} + +// Upsert 按 (dataset_id, name) 去重,已存在则更新类型与来源 +func (d *kgEntityDao) Upsert(ctx context.Context, datasetId, chunkId int64, name, entityType string) error { + now := gtime.Now().Format("Y-m-d H:i:s") + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `INSERT INTO `+consts.TableNameKgEntity+` + (dataset_id, name, entity_type, chunk_id, created_at, updated_at) + VALUES (?,?,?,?,?,?) + ON CONFLICT(dataset_id, name) DO UPDATE SET + entity_type=excluded.entity_type, chunk_id=excluded.chunk_id, updated_at=excluded.updated_at`, + datasetId, name, entityType, chunkId, now, now) + return err +} + +func (d *kgEntityDao) DeleteByChunkIds(ctx context.Context, chunkIds []int64) error { + if len(chunkIds) == 0 { + return nil + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(chunkIds)), ",") + args := make([]any, 0, len(chunkIds)) + for _, id := range chunkIds { + args = append(args, id) + } + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, + "DELETE FROM "+consts.TableNameKgEntity+" WHERE chunk_id IN ("+placeholders+")", args...) + return err +} + +func (d *kgEntityDao) DeleteByDataset(ctx context.Context, datasetId int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgEntity).Ctx(ctx). + Where("dataset_id", datasetId).Delete() + return err +} diff --git a/kb/dao/kg_relation_dao.go b/kb/dao/kg_relation_dao.go new file mode 100644 index 0000000..d3e68be --- /dev/null +++ b/kb/dao/kg_relation_dao.go @@ -0,0 +1,137 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + "strings" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var KgRelation = &kgRelationDao{} + +type kgRelationDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameKgRelation+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dataset_id INTEGER NOT NULL DEFAULT 0, + head TEXT NOT NULL DEFAULT '', + relation TEXT NOT NULL DEFAULT '', + tail TEXT NOT NULL DEFAULT '', + chunk_id INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kg_relation table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kg_relation_dataset ON "+consts.TableNameKgRelation+"(dataset_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kg_relation_dataset failed: %v", err) + } +} + +func (d *kgRelationDao) GetOne(ctx context.Context, id int64) (*entity.KgRelation, error) { + var m entity.KgRelation + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgRelation).Ctx(ctx).Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *kgRelationDao) List(ctx context.Context, datasetId int64, page, pageSize int) ([]*entity.KgRelation, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + m := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgRelation).Ctx(ctx) + if datasetId > 0 { + m = m.Where("dataset_id", datasetId) + } + total, err := m.Count() + if err != nil { + return nil, 0, err + } + var list []*entity.KgRelation + err = m.Page(page, pageSize).OrderDesc("id").Scan(&list) + return list, total, err +} + +func (d *kgRelationDao) Insert(ctx context.Context, datasetId, chunkId int64, head, relation, tail string) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgRelation).Ctx(ctx).Data(g.Map{ + "dataset_id": datasetId, + "head": head, + "relation": relation, + "tail": tail, + "chunk_id": chunkId, + "created_at": gtime.Now().Format("Y-m-d H:i:s"), + }).Insert() + return err +} + +// Neighbors 一跳邻居:head 或 tail 命中实体名的三元组(实体链接用) +func (d *kgRelationDao) Neighbors(ctx context.Context, datasetId int64, names []string, limit int) ([]*entity.KgRelation, error) { + if len(names) == 0 { + return nil, nil + } + if limit < 1 { + limit = 20 + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(names)), ",") + args := make([]any, 0, len(names)*2+2) + for _, n := range names { + args = append(args, n, n) + } + args = append(args, datasetId, limit) + r, err := g.DB(consts.DbGroupDefault).Ctx(ctx).Raw(` + SELECT id, dataset_id, head, relation, tail, chunk_id, created_at + FROM `+consts.TableNameKgRelation+` + WHERE (head IN (`+placeholders+`) OR tail IN (`+placeholders+`)) AND dataset_id = ? + ORDER BY id LIMIT ?`, args...).All() + if err != nil { + return nil, err + } + list := make([]*entity.KgRelation, 0, len(r)) + for _, row := range r { + list = append(list, &entity.KgRelation{ + Id: row["id"].Int64(), + DatasetId: row["dataset_id"].Int64(), + Head: row["head"].String(), + Relation: row["relation"].String(), + Tail: row["tail"].String(), + ChunkId: row["chunk_id"].Int64(), + }) + } + return list, nil +} + +func (d *kgRelationDao) DeleteByChunkIds(ctx context.Context, chunkIds []int64) error { + if len(chunkIds) == 0 { + return nil + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(chunkIds)), ",") + args := make([]any, 0, len(chunkIds)) + for _, id := range chunkIds { + args = append(args, id) + } + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, + "DELETE FROM "+consts.TableNameKgRelation+" WHERE chunk_id IN ("+placeholders+")", args...) + return err +} + +func (d *kgRelationDao) DeleteByDataset(ctx context.Context, datasetId int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameKgRelation).Ctx(ctx). + Where("dataset_id", datasetId).Delete() + return err +} diff --git a/kb/dao/message_dao.go b/kb/dao/message_dao.go new file mode 100644 index 0000000..041c90a --- /dev/null +++ b/kb/dao/message_dao.go @@ -0,0 +1,54 @@ +package dao + +import ( + "context" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var Message = &messageDao{} + +type messageDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupChat).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameMessage+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL DEFAULT 0, + role TEXT NOT NULL DEFAULT 'user', + content TEXT NOT NULL DEFAULT '', + citations TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create chat_message table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupChat).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_chat_message_conversation ON "+consts.TableNameMessage+"(conversation_id)"); err != nil { + g.Log().Warningf(ctx, "create index idx_chat_message_conversation failed: %v", err) + } +} + +func (d *messageDao) List(ctx context.Context, conversationId int64) ([]*entity.Message, error) { + var list []*entity.Message + err := g.DB(consts.DbGroupChat).Model(consts.TableNameMessage).Ctx(ctx). + Where("conversation_id", conversationId).OrderAsc("id").Scan(&list) + return list, err +} + +func (d *messageDao) Insert(ctx context.Context, conversationId int64, role, content, citations string) (int64, error) { + r, err := g.DB(consts.DbGroupChat).Model(consts.TableNameMessage).Ctx(ctx).Data(g.Map{ + "conversation_id": conversationId, + "role": role, + "content": content, + "citations": citations, + "created_at": gtime.Now().Format("Y-m-d H:i:s"), + }).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} diff --git a/kb/dao/model_config_dao.go b/kb/dao/model_config_dao.go new file mode 100644 index 0000000..758eb23 --- /dev/null +++ b/kb/dao/model_config_dao.go @@ -0,0 +1,110 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + + "rag-local/common" + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcache" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" +) + +var ModelConfig = &modelConfigDao{} + +type modelConfigDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupSystem).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameModelConfig+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + model_type TEXT NOT NULL DEFAULT 'chat', + model_name TEXT NOT NULL DEFAULT '', + endpoint_url TEXT NOT NULL DEFAULT '', + api_key TEXT NOT NULL DEFAULT '', + dimension INTEGER NOT NULL DEFAULT 1024, + extra TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create model_config table failed: %v", err) + } +} + +func (d *modelConfigDao) GetOne(ctx context.Context, id int64) (*entity.ModelConfig, error) { + var m entity.ModelConfig + err := g.DB(consts.DbGroupSystem).Model(consts.TableNameModelConfig).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: "model_config_GetOne_" + gconv.String(id)}). + Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *modelConfigDao) List(ctx context.Context, modelType string) ([]*entity.ModelConfig, error) { + var list []*entity.ModelConfig + m := g.DB(consts.DbGroupSystem).Model(consts.TableNameModelConfig).Ctx(ctx).OrderAsc("id") + if modelType != "" { + m = m.Where("model_type", modelType) + } + err := m.Scan(&list) + return list, err +} + +func (d *modelConfigDao) Insert(ctx context.Context, data *entity.ModelConfig) (int64, error) { + now := gtime.Now().Format("Y-m-d H:i:s") + r, err := g.DB(consts.DbGroupSystem).Model(consts.TableNameModelConfig).Ctx(ctx).Data(g.Map{ + "name": data.Name, + "model_type": data.ModelType, + "model_name": data.ModelName, + "endpoint_url": data.EndpointUrl, + "api_key": data.ApiKey, + "dimension": data.Dimension, + "extra": data.Extra, + "created_at": now, + "updated_at": now, + }).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *modelConfigDao) Update(ctx context.Context, data *entity.ModelConfig) error { + _, err := g.DB(consts.DbGroupSystem).Model(consts.TableNameModelConfig).Ctx(ctx).Data(g.Map{ + "name": data.Name, + "model_type": data.ModelType, + "model_name": data.ModelName, + "endpoint_url": data.EndpointUrl, + "api_key": data.ApiKey, + "dimension": data.Dimension, + "extra": data.Extra, + "updated_at": gtime.Now().Format("Y-m-d H:i:s"), + }).Where("id", data.Id).Update() + if err != nil { + return err + } + _, _ = gcache.Remove(ctx, "model_config_GetOne_"+gconv.String(data.Id)) + return nil +} + +func (d *modelConfigDao) Delete(ctx context.Context, id int64) error { + _, err := g.DB(consts.DbGroupSystem).Model(consts.TableNameModelConfig).Ctx(ctx).Where("id", id).Delete() + if err != nil { + return err + } + _, _ = gcache.Remove(ctx, "model_config_GetOne_"+gconv.String(id)) + return nil +} diff --git a/kb/dao/parse_task_dao.go b/kb/dao/parse_task_dao.go new file mode 100644 index 0000000..c54018f --- /dev/null +++ b/kb/dao/parse_task_dao.go @@ -0,0 +1,110 @@ +package dao + +import ( + "context" + "database/sql" + "errors" + + "rag-local/kb/consts" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +var ParseTask = &parseTaskDao{} + +type parseTaskDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupDefault).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameParseTask+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id INTEGER NOT NULL DEFAULT 0, + dataset_id INTEGER NOT NULL DEFAULT 0, + task_type TEXT NOT NULL DEFAULT 'parse', + status INTEGER NOT NULL DEFAULT 0, + error_msg TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create kb_parse_task table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupDefault).Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_kb_parse_task_status ON "+consts.TableNameParseTask+"(status)"); err != nil { + g.Log().Warningf(ctx, "create index idx_kb_parse_task_status failed: %v", err) + } +} + +func (d *parseTaskDao) GetOne(ctx context.Context, id int64) (*entity.ParseTask, error) { + var m entity.ParseTask + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameParseTask).Ctx(ctx).Where("id", id).Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (d *parseTaskDao) List(ctx context.Context, page, pageSize int) ([]*entity.ParseTask, int, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + total, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameParseTask).Ctx(ctx).Count() + if err != nil { + return nil, 0, err + } + var list []*entity.ParseTask + err = g.DB(consts.DbGroupDefault).Model(consts.TableNameParseTask).Ctx(ctx). + Page(page, pageSize).OrderDesc("id").Scan(&list) + return list, total, err +} + +func (d *parseTaskDao) Insert(ctx context.Context, documentId, datasetId int64, taskType string) (int64, error) { + now := gtime.Now().Format("Y-m-d H:i:s") + r, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameParseTask).Ctx(ctx).Data(g.Map{ + "document_id": documentId, + "dataset_id": datasetId, + "task_type": taskType, + "status": consts.TaskStatusPending, + "created_at": now, + "updated_at": now, + }).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *parseTaskDao) UpdateStatus(ctx context.Context, id int64, status int, errorMsg string) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameParseTask).Ctx(ctx).Data(g.Map{ + "status": status, + "error_msg": errorMsg, + "updated_at": gtime.Now().Format("Y-m-d H:i:s"), + }).Where("id", id).Update() + return err +} + +func (d *parseTaskDao) DeleteByDocument(ctx context.Context, documentId int64) error { + _, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameParseTask).Ctx(ctx). + Where("document_id", documentId).Delete() + return err +} + +func (d *parseTaskDao) NextPending(ctx context.Context) (*entity.ParseTask, error) { + var m entity.ParseTask + err := g.DB(consts.DbGroupDefault).Model(consts.TableNameParseTask).Ctx(ctx). + Where("status", consts.TaskStatusPending).OrderAsc("id").Scan(&m) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} diff --git a/kb/dao/system_config_dao.go b/kb/dao/system_config_dao.go new file mode 100644 index 0000000..eb13ba1 --- /dev/null +++ b/kb/dao/system_config_dao.go @@ -0,0 +1,57 @@ +package dao + +import ( + "context" + + "rag-local/common" + "rag-local/kb/consts" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcache" +) + +var SystemConfig = &systemConfigDao{} + +type systemConfigDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB(consts.DbGroupSystem).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameSystemConfig+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cfg_key TEXT NOT NULL DEFAULT '', + cfg_value TEXT NOT NULL DEFAULT '', + updated_at DATETIME DEFAULT (datetime('now','localtime')) + )`) + if err != nil { + g.Log().Warningf(ctx, "create system_config table failed: %v", err) + } + if _, err := g.DB(consts.DbGroupSystem).Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_system_config_key ON "+consts.TableNameSystemConfig+"(cfg_key)"); err != nil { + g.Log().Warningf(ctx, "create index idx_system_config_key failed: %v", err) + } +} + +func (d *systemConfigDao) Get(ctx context.Context, key string) (string, error) { + r, err := g.DB(consts.DbGroupSystem).Model(consts.TableNameSystemConfig).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: "system_config_Get_" + key}). + Fields("cfg_value").Where("cfg_key", key).One() + if err != nil { + return "", err + } + if r == nil { + return "", nil + } + return r["cfg_value"].String(), nil +} + +func (d *systemConfigDao) Set(ctx context.Context, key, value string) error { + _, err := g.DB(consts.DbGroupSystem).Exec(ctx, + "INSERT INTO "+consts.TableNameSystemConfig+" (cfg_key, cfg_value, updated_at) VALUES (?, ?, datetime('now','localtime')) "+ + "ON CONFLICT(cfg_key) DO UPDATE SET cfg_value=excluded.cfg_value, updated_at=datetime('now','localtime')", + key, value) + if err != nil { + return err + } + _, _ = gcache.Remove(ctx, "system_config_Get_"+key) + return nil +} diff --git a/kb/model/domain/retrieval.go b/kb/model/domain/retrieval.go new file mode 100644 index 0000000..bbee6bb --- /dev/null +++ b/kb/model/domain/retrieval.go @@ -0,0 +1,66 @@ +package domain + +import ( + "strconv" + "strings" + + "rag-local/kb/model/entity" +) + +// VecHit 向量 KNN 命中(vec0 默认 L2 距离) +type VecHit struct { + ChunkId int64 + Distance float64 +} + +// FtsHit 全文检索命中(bm25 负分,越小越相关) +type FtsHit struct { + ChunkId int64 + Score float64 +} + +// RetrievedChunk 混合检索融合后的结果 +type RetrievedChunk struct { + Chunk *entity.Chunk + Score float64 + // Sources 命中来源:"vector" / "fts" / "hybrid" + Sources []string +} + +// HybridResult 混合检索整体结果(chunk + 知识图谱三元组,M5 扩展) +type HybridResult struct { + Chunks []*RetrievedChunk +} + +// Citation 回答引用来源(与回答文本中 [1][2] 编号对应) +type Citation struct { + Index int `json:"index"` + DocumentId int64 `json:"document_id"` + ChunkId int64 `json:"chunk_id"` + Content string `json:"content"` + Score float64 `json:"score"` + Sources []string `json:"sources"` +} + +// VecJson 向量 JSON 序列化([0.1,0.2,...]) +func VecJson(vec []float32) string { + return vecJsonFloat(vec) +} + +// VecJsonF64 float64 向量 JSON 序列化(embedding 接口返回 float64) +func VecJsonF64(vec []float64) string { + return vecJsonFloat(vec) +} + +func vecJsonFloat[T float32 | float64](vec []T) string { + var sb strings.Builder + sb.WriteByte('[') + for i, v := range vec { + if i > 0 { + sb.WriteByte(',') + } + sb.WriteString(strconv.FormatFloat(float64(v), 'f', -1, 32)) + } + sb.WriteByte(']') + return sb.String() +} diff --git a/kb/model/dto/chunk_dto.go b/kb/model/dto/chunk_dto.go new file mode 100644 index 0000000..b530772 --- /dev/null +++ b/kb/model/dto/chunk_dto.go @@ -0,0 +1,29 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListChunkReq struct { + g.Meta `path:"/list" method:"get" tags:"分块" summary:"分块列表"` + DocumentId int64 `v:"required" json:"document_id"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListChunkRes struct { + List []*entity.Chunk `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type UpdateChunkReq struct { + g.Meta `path:"/update" method:"post" tags:"分块" summary:"编辑分块(改文本后重新向量化)"` + Id int64 `v:"required" json:"id"` + Content string `v:"required" json:"content"` +} + +type UpdateChunkRes struct{} diff --git a/kb/model/dto/conversation_dto.go b/kb/model/dto/conversation_dto.go new file mode 100644 index 0000000..7ef5945 --- /dev/null +++ b/kb/model/dto/conversation_dto.go @@ -0,0 +1,33 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListConversationReq struct { + g.Meta `path:"/list" method:"get" tags:"会话" summary:"会话列表"` +} + +type ListConversationRes struct { + List []*entity.Conversation `json:"list"` +} + +type SaveConversationReq struct { + g.Meta `path:"/save" method:"post" tags:"会话" summary:"保存会话"` + Id int64 `json:"id"` + DatasetId int64 `json:"dataset_id"` + Title string `json:"title"` +} + +type SaveConversationRes struct { + Id int64 `json:"id"` +} + +type DeleteConversationReq struct { + g.Meta `path:"/delete" method:"post" tags:"会话" summary:"删除会话"` + Id int64 `v:"required" json:"id"` +} + +type DeleteConversationRes struct{} diff --git a/kb/model/dto/dataset_dto.go b/kb/model/dto/dataset_dto.go new file mode 100644 index 0000000..536d0fa --- /dev/null +++ b/kb/model/dto/dataset_dto.go @@ -0,0 +1,34 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListDatasetReq struct { + g.Meta `path:"/list" method:"get" tags:"数据集" summary:"数据集列表"` +} + +type ListDatasetRes struct { + List []*entity.Dataset `json:"list"` +} + +type SaveDatasetReq struct { + g.Meta `path:"/save" method:"post" tags:"数据集" summary:"保存数据集"` + Id int64 `json:"id"` + Name string `v:"required" json:"name"` + Description string `json:"description"` + EmbeddingCfgId int64 `json:"embedding_cfg_id"` +} + +type SaveDatasetRes struct { + Id int64 `json:"id"` +} + +type DeleteDatasetReq struct { + g.Meta `path:"/delete" method:"post" tags:"数据集" summary:"删除数据集"` + Id int64 `v:"required" json:"id"` +} + +type DeleteDatasetRes struct{} diff --git a/kb/model/dto/document_dto.go b/kb/model/dto/document_dto.go new file mode 100644 index 0000000..9f1937e --- /dev/null +++ b/kb/model/dto/document_dto.go @@ -0,0 +1,46 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +type UploadDocumentReq struct { + g.Meta `path:"/upload" method:"post" tags:"文档" summary:"上传文档"` + DatasetId int64 `json:"dataset_id"` + File *ghttp.UploadFile `json:"file" dc:"文档文件"` +} + +type UploadDocumentRes struct { + Id int64 `json:"id"` +} + +type ListDocumentReq struct { + g.Meta `path:"/list" method:"get" tags:"文档" summary:"文档列表"` + DatasetId int64 `v:"required" json:"dataset_id"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListDocumentRes struct { + List []*entity.Document `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type DeleteDocumentReq struct { + g.Meta `path:"/delete" method:"post" tags:"文档" summary:"删除文档"` + Id int64 `v:"required" json:"id"` +} + +type DeleteDocumentRes struct{} + +type ReembedDocumentReq struct { + g.Meta `path:"/reembed" method:"post" tags:"文档" summary:"重新向量化"` + Id int64 `v:"required" json:"id"` +} + +type ReembedDocumentRes struct{} diff --git a/kb/model/dto/kg_entity_dto.go b/kb/model/dto/kg_entity_dto.go new file mode 100644 index 0000000..d75319d --- /dev/null +++ b/kb/model/dto/kg_entity_dto.go @@ -0,0 +1,21 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListKgEntityReq struct { + g.Meta `path:"/list" method:"get" tags:"知识图谱" summary:"实体列表"` + DatasetId int64 `json:"dataset_id"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListKgEntityRes struct { + List []*entity.KgEntity `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/kb/model/dto/kg_relation_dto.go b/kb/model/dto/kg_relation_dto.go new file mode 100644 index 0000000..e48e8d4 --- /dev/null +++ b/kb/model/dto/kg_relation_dto.go @@ -0,0 +1,21 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListKgRelationReq struct { + g.Meta `path:"/list" method:"get" tags:"知识图谱" summary:"关系列表"` + DatasetId int64 `json:"dataset_id"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListKgRelationRes struct { + List []*entity.KgRelation `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/kb/model/dto/message_dto.go b/kb/model/dto/message_dto.go new file mode 100644 index 0000000..b60b864 --- /dev/null +++ b/kb/model/dto/message_dto.go @@ -0,0 +1,26 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListMessageReq struct { + g.Meta `path:"/list" method:"get" tags:"消息" summary:"历史消息"` + ConversationId int64 `v:"required" json:"conversation_id"` +} + +type ListMessageRes struct { + List []*entity.Message `json:"list"` +} + +// ChatReq RAG 问答(SSE 流式响应,非标准 JSON 包装) +type ChatReq struct { + g.Meta `path:"/chat" method:"post" tags:"消息" summary:"RAG 问答(SSE 流式)"` + ConversationId int64 `json:"conversation_id"` + DatasetId int64 `json:"dataset_id"` + Question string `v:"required" json:"question"` +} + +type ChatRes struct{} diff --git a/kb/model/dto/model_config_dto.go b/kb/model/dto/model_config_dto.go new file mode 100644 index 0000000..49af794 --- /dev/null +++ b/kb/model/dto/model_config_dto.go @@ -0,0 +1,49 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListModelConfigReq struct { + g.Meta `path:"/list" method:"get" tags:"模型配置" summary:"模型配置列表"` + ModelType string `json:"model_type"` +} + +type ListModelConfigRes struct { + List []*entity.ModelConfig `json:"list"` +} + +type SaveModelConfigReq struct { + g.Meta `path:"/save" method:"post" tags:"模型配置" summary:"保存模型配置"` + Id int64 `json:"id"` + Name string `v:"required" json:"name"` + ModelType string `v:"required|in:chat,embedding" json:"model_type"` + ModelName string `v:"required" json:"model_name"` + EndpointUrl string `json:"endpoint_url"` + ApiKey string `json:"api_key"` + Dimension int `json:"dimension"` + Extra string `json:"extra"` +} + +type SaveModelConfigRes struct { + Id int64 `json:"id"` +} + +type DeleteModelConfigReq struct { + g.Meta `path:"/delete" method:"post" tags:"模型配置" summary:"删除模型配置"` + Id int64 `v:"required" json:"id"` +} + +type DeleteModelConfigRes struct{} + +type TestModelConfigReq struct { + g.Meta `path:"/test" method:"post" tags:"模型配置" summary:"连通性测试"` + Id int64 `v:"required" json:"id"` +} + +type TestModelConfigRes struct { + Ok bool `json:"ok"` + Msg string `json:"msg"` +} diff --git a/kb/model/dto/parse_task_dto.go b/kb/model/dto/parse_task_dto.go new file mode 100644 index 0000000..638c651 --- /dev/null +++ b/kb/model/dto/parse_task_dto.go @@ -0,0 +1,27 @@ +package dto + +import ( + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListParseTaskReq struct { + g.Meta `path:"/list" method:"get" tags:"解析任务" summary:"解析任务列表"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListParseTaskRes struct { + List []*entity.ParseTask `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type RetryParseTaskReq struct { + g.Meta `path:"/retry" method:"post" tags:"解析任务" summary:"重试失败任务"` + Id int64 `v:"required" json:"id"` +} + +type RetryParseTaskRes struct{} diff --git a/kb/model/dto/system_config_dto.go b/kb/model/dto/system_config_dto.go new file mode 100644 index 0000000..34199e7 --- /dev/null +++ b/kb/model/dto/system_config_dto.go @@ -0,0 +1,45 @@ +package dto + +import "github.com/gogf/gf/v2/frame/g" + +type LoginReq struct { + g.Meta `path:"/login" method:"post" tags:"系统配置" summary:"访问令牌登录"` + Token string `v:"required" json:"token"` +} + +type LoginRes struct { + Token string `json:"token"` +} + +type GetSystemConfigReq struct { + g.Meta `path:"/" method:"get" tags:"系统配置" summary:"获取系统设置"` +} + +type GetSystemConfigRes struct { + DefaultChatModel int64 `json:"default_chat_model"` + DefaultDataset int64 `json:"default_dataset"` +} + +type UpdateSystemConfigReq struct { + g.Meta `path:"/" method:"put" tags:"系统配置" summary:"更新系统设置"` + DefaultChatModel int64 `json:"default_chat_model"` + DefaultDataset int64 `json:"default_dataset"` +} + +type UpdateSystemConfigRes struct{} + +type RegenerateTokenReq struct { + g.Meta `path:"/regenerate-token" method:"post" tags:"系统配置" summary:"重新生成访问令牌"` +} + +type RegenerateTokenRes struct { + Token string `json:"token"` +} + +type GetTokenReq struct { + g.Meta `path:"/token" method:"get" tags:"系统配置" summary:"获取当前访问令牌"` +} + +type GetTokenRes struct { + Token string `json:"token"` +} diff --git a/kb/model/entity/chunk.go b/kb/model/entity/chunk.go new file mode 100644 index 0000000..ae5ee03 --- /dev/null +++ b/kb/model/entity/chunk.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type Chunk struct { + Id int64 `orm:"id" json:"id"` + DatasetId int64 `orm:"dataset_id" json:"dataset_id"` + DocumentId int64 `orm:"document_id" json:"document_id"` + Seq int `orm:"seq" json:"seq"` + Content string `orm:"content" json:"content"` + Meta string `orm:"meta" json:"meta"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/kb/model/entity/conversation.go b/kb/model/entity/conversation.go new file mode 100644 index 0000000..15c25a6 --- /dev/null +++ b/kb/model/entity/conversation.go @@ -0,0 +1,11 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type Conversation struct { + Id int64 `orm:"id" json:"id"` + DatasetId int64 `orm:"dataset_id" json:"dataset_id"` + Title string `orm:"title" json:"title"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/model/entity/dataset.go b/kb/model/entity/dataset.go new file mode 100644 index 0000000..2505ac6 --- /dev/null +++ b/kb/model/entity/dataset.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type Dataset struct { + Id int64 `orm:"id" json:"id"` + Name string `orm:"name" json:"name"` + Description string `orm:"description" json:"description"` + EmbeddingCfgId int64 `orm:"embedding_cfg_id" json:"embedding_cfg_id"` + Status int `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/model/entity/document.go b/kb/model/entity/document.go new file mode 100644 index 0000000..126a647 --- /dev/null +++ b/kb/model/entity/document.go @@ -0,0 +1,17 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type Document struct { + Id int64 `orm:"id" json:"id"` + DatasetId int64 `orm:"dataset_id" json:"dataset_id"` + Filename string `orm:"filename" json:"filename"` + FilePath string `orm:"file_path" json:"file_path"` + FileSize int64 `orm:"file_size" json:"file_size"` + FileType string `orm:"file_type" json:"file_type"` + Status int `orm:"status" json:"status"` + ChunkCount int `orm:"chunk_count" json:"chunk_count"` + ErrorMsg string `orm:"error_msg" json:"error_msg"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/model/entity/kg_entity.go b/kb/model/entity/kg_entity.go new file mode 100644 index 0000000..0cb439e --- /dev/null +++ b/kb/model/entity/kg_entity.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type KgEntity struct { + Id int64 `orm:"id" json:"id"` + DatasetId int64 `orm:"dataset_id" json:"dataset_id"` + Name string `orm:"name" json:"name"` + EntityType string `orm:"entity_type" json:"entity_type"` + ChunkId int64 `orm:"chunk_id" json:"chunk_id"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/model/entity/kg_relation.go b/kb/model/entity/kg_relation.go new file mode 100644 index 0000000..c1c50c1 --- /dev/null +++ b/kb/model/entity/kg_relation.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type KgRelation struct { + Id int64 `orm:"id" json:"id"` + DatasetId int64 `orm:"dataset_id" json:"dataset_id"` + Head string `orm:"head" json:"head"` + Relation string `orm:"relation" json:"relation"` + Tail string `orm:"tail" json:"tail"` + ChunkId int64 `orm:"chunk_id" json:"chunk_id"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/kb/model/entity/message.go b/kb/model/entity/message.go new file mode 100644 index 0000000..5b4f1c9 --- /dev/null +++ b/kb/model/entity/message.go @@ -0,0 +1,12 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type Message struct { + Id int64 `orm:"id" json:"id"` + ConversationId int64 `orm:"conversation_id" json:"conversation_id"` + Role string `orm:"role" json:"role"` + Content string `orm:"content" json:"content"` + Citations string `orm:"citations" json:"citations"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/kb/model/entity/model_config.go b/kb/model/entity/model_config.go new file mode 100644 index 0000000..b436783 --- /dev/null +++ b/kb/model/entity/model_config.go @@ -0,0 +1,16 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type ModelConfig struct { + Id int64 `orm:"id" json:"id"` + Name string `orm:"name" json:"name"` + ModelType string `orm:"model_type" json:"model_type"` + ModelName string `orm:"model_name" json:"model_name"` + EndpointUrl string `orm:"endpoint_url" json:"endpoint_url"` + ApiKey string `orm:"api_key" json:"api_key"` + Dimension int `orm:"dimension" json:"dimension"` + Extra string `orm:"extra" json:"extra"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/model/entity/parse_task.go b/kb/model/entity/parse_task.go new file mode 100644 index 0000000..cb16511 --- /dev/null +++ b/kb/model/entity/parse_task.go @@ -0,0 +1,14 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type ParseTask struct { + Id int64 `orm:"id" json:"id"` + DocumentId int64 `orm:"document_id" json:"document_id"` + DatasetId int64 `orm:"dataset_id" json:"dataset_id"` + TaskType string `orm:"task_type" json:"task_type"` + Status int `orm:"status" json:"status"` + ErrorMsg string `orm:"error_msg" json:"error_msg"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/model/entity/system_config.go b/kb/model/entity/system_config.go new file mode 100644 index 0000000..c9596f8 --- /dev/null +++ b/kb/model/entity/system_config.go @@ -0,0 +1,10 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type SystemConfig struct { + Id int64 `orm:"id" json:"id"` + CfgKey string `orm:"cfg_key" json:"cfg_key"` + CfgValue string `orm:"cfg_value" json:"cfg_value"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/kb/service/chat_service.go b/kb/service/chat_service.go new file mode 100644 index 0000000..2088309 --- /dev/null +++ b/kb/service/chat_service.go @@ -0,0 +1,476 @@ +package service + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "rag-local/common" + "rag-local/kb/consts" + "rag-local/kb/dao" + "rag-local/kb/model/domain" + "rag-local/kb/model/entity" + + eembedding "github.com/cloudwego/eino/components/embedding" + emodel "github.com/cloudwego/eino/components/model" + eretriever "github.com/cloudwego/eino/components/retriever" + "github.com/cloudwego/eino/schema" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +var httpClient = &http.Client{Timeout: 2 * time.Minute} + +// ---------- OpenAI 兼容 HTTP 组件 ---------- + +type openAIMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type openAIChatResponse struct { + Choices []struct { + Message openAIMessage `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage *schema.TokenUsage `json:"usage"` +} + +type openAIStreamChunk struct { + Choices []struct { + Delta openAIMessage `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` +} + +// OpenAIChatModel 基于 OpenAI 兼容 /chat/completions 接口的对话模型,实现 eino model.ChatModel +type OpenAIChatModel struct { + cfg *entity.ModelConfig +} + +func NewOpenAIChatModel(cfg *entity.ModelConfig) *OpenAIChatModel { + return &OpenAIChatModel{cfg: cfg} +} + +func (m *OpenAIChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...emodel.Option) (*schema.Message, error) { + payload := map[string]any{ + "model": m.cfg.ModelName, + "messages": buildOpenAIMessages(input), + "stream": false, + } + body, err := postOpenAI(ctx, m.cfg, m.endpoint("/chat/completions"), payload) + if err != nil { + return nil, err + } + var resp openAIChatResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, gerror.Wrap(err, "解析模型响应失败") + } + if len(resp.Choices) == 0 { + return nil, gerror.New("模型返回空响应") + } + choice := resp.Choices[0] + msg := &schema.Message{Role: schema.Assistant, Content: choice.Message.Content} + if choice.FinishReason != "" { + msg.ResponseMeta = &schema.ResponseMeta{FinishReason: choice.FinishReason, Usage: resp.Usage} + } + return msg, nil +} + +func (m *OpenAIChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...emodel.Option) (*schema.StreamReader[*schema.Message], error) { + payload := map[string]any{ + "model": m.cfg.ModelName, + "messages": buildOpenAIMessages(input), + "stream": true, + } + reader, writer := schema.Pipe[*schema.Message](16) + go func() { + defer writer.Close() + body, err := postOpenAIStream(ctx, m.cfg, m.endpoint("/chat/completions"), payload) + if err != nil { + writer.Send(nil, err) + return + } + defer body.Close() + br := bufio.NewReader(body) + for { + line, err := br.ReadBytes('\n') + text := strings.TrimSpace(string(line)) + if strings.HasPrefix(text, "data:") { + data := strings.TrimSpace(strings.TrimPrefix(text, "data:")) + if data == "[DONE]" { + break + } + var chunk openAIStreamChunk + if json.Unmarshal([]byte(data), &chunk) == nil && len(chunk.Choices) > 0 { + if delta := chunk.Choices[0].Delta.Content; delta != "" { + if closed := writer.Send(&schema.Message{Role: schema.Assistant, Content: delta}, nil); closed { + return + } + } + } + } + if err != nil { + break + } + } + }() + return reader, nil +} + +func (m *OpenAIChatModel) BindTools(tools []*schema.ToolInfo) error { + return nil +} + +func (m *OpenAIChatModel) endpoint(path string) string { + return strings.TrimRight(m.cfg.EndpointUrl, "/") + path +} + +// OpenAIEmbedder 基于 OpenAI 兼容 /embeddings 接口的向量模型,实现 eino embedding.Embedder +type OpenAIEmbedder struct { + cfg *entity.ModelConfig +} + +func NewOpenAIEmbedder(cfg *entity.ModelConfig) *OpenAIEmbedder { + return &OpenAIEmbedder{cfg: cfg} +} + +// Dim 配置的向量维度(vec0 表建表维度需一致) +func (e *OpenAIEmbedder) Dim() int { + if e.cfg.Dimension > 0 { + return e.cfg.Dimension + } + return consts.DefaultEmbeddingDim +} + +func (e *OpenAIEmbedder) EmbedStrings(ctx context.Context, texts []string, opts ...eembedding.Option) ([][]float64, error) { + payload := map[string]any{"model": e.cfg.ModelName, "input": texts} + body, err := postOpenAI(ctx, e.cfg, strings.TrimRight(e.cfg.EndpointUrl, "/")+"/embeddings", payload) + if err != nil { + return nil, err + } + var resp struct { + Data []struct { + Embedding []float64 `json:"embedding"` + Index int `json:"index"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, gerror.Wrap(err, "解析 embedding 响应失败") + } + if len(resp.Data) == 0 { + return nil, gerror.New("embedding 接口返回空数据") + } + out := make([][]float64, len(texts)) + for _, d := range resp.Data { + if d.Index >= 0 && d.Index < len(out) { + out[d.Index] = d.Embedding + } + } + return out, nil +} + +// BuildChatModel 按配置 id 构建对话模型 +func BuildChatModel(ctx context.Context, cfgId int64) (*OpenAIChatModel, error) { + cfg, err := dao.ModelConfig.GetOne(ctx, cfgId) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, gerror.New("模型配置不存在") + } + if cfg.ModelType != consts.ModelTypeChat { + return nil, gerror.New("该模型配置不是对话模型(model_type=chat)") + } + if cfg.EndpointUrl == "" || cfg.ModelName == "" { + return nil, gerror.New("模型配置缺少 endpoint_url 或 model_name") + } + return NewOpenAIChatModel(cfg), nil +} + +// BuildEmbedder 按配置 id 构建向量模型 +func BuildEmbedder(ctx context.Context, cfgId int64) (*OpenAIEmbedder, error) { + cfg, err := dao.ModelConfig.GetOne(ctx, cfgId) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, gerror.New("模型配置不存在") + } + if cfg.ModelType != consts.ModelTypeEmbedding { + return nil, gerror.New("该模型配置不是向量模型(model_type=embedding)") + } + if cfg.EndpointUrl == "" || cfg.ModelName == "" { + return nil, gerror.New("模型配置缺少 endpoint_url 或 model_name") + } + return NewOpenAIEmbedder(cfg), nil +} + +// HybridRetriever 混合检索器:向量 KNN + FTS5 BM25,RRF 融合,实现 eino retriever.Retriever +type HybridRetriever struct { + embedder eembedding.Embedder + datasetId int64 +} + +func NewHybridRetriever(embedder eembedding.Embedder, datasetId int64) *HybridRetriever { + return &HybridRetriever{embedder: embedder, datasetId: datasetId} +} + +func (r *HybridRetriever) Retrieve(ctx context.Context, query string, opts ...eretriever.Option) ([]*schema.Document, error) { + o := eretriever.GetCommonOptions(nil, opts...) + topK := consts.HybridTopK + if o.TopK != nil && *o.TopK > 0 { + topK = *o.TopK + } + + scores := make(map[int64]float64) + srcs := make(map[int64][]string) + + if r.embedder != nil { + vecs, err := r.embedder.EmbedStrings(ctx, []string{query}) + if err != nil { + g.Log().Warningf(ctx, "query embed failed: %v", err) + } else if len(vecs) > 0 { + hits, err := dao.Chunk.VecSearch(ctx, r.datasetId, domain.VecJsonF64(vecs[0]), consts.VectorTopK) + if err != nil { + g.Log().Warningf(ctx, "vec search failed: %v", err) + } else { + for i, h := range hits { + scores[h.ChunkId] += 1 / (float64(consts.RrfK) + float64(i) + 1) + srcs[h.ChunkId] = append(srcs[h.ChunkId], "vector") + } + } + } + } + + ftsHits, err := dao.Chunk.FtsSearch(ctx, r.datasetId, common.TokenizeQuery(query), consts.FtsTopK) + if err != nil { + g.Log().Warningf(ctx, "fts search failed: %v", err) + } else { + for i, h := range ftsHits { + scores[h.ChunkId] += 1 / (float64(consts.RrfK) + float64(i) + 1) + srcs[h.ChunkId] = append(srcs[h.ChunkId], "fts") + } + } + + type scoredChunk struct { + id int64 + score float64 + sources []string + } + items := make([]scoredChunk, 0, len(scores)) + for id, s := range scores { + items = append(items, scoredChunk{id: id, score: s, sources: srcs[id]}) + } + sort.Slice(items, func(i, j int) bool { return items[i].score > items[j].score }) + if len(items) > topK { + items = items[:topK] + } + + docs := make([]*schema.Document, 0, len(items)) + for _, it := range items { + chunk, err := dao.Chunk.GetOne(ctx, it.id) + if err != nil || chunk == nil { + continue + } + docs = append(docs, &schema.Document{ + ID: strconv.FormatInt(chunk.Id, 10), + Content: chunk.Content, + MetaData: map[string]any{ + "chunk_id": chunk.Id, + "document_id": chunk.DocumentId, + "seq": chunk.Seq, + "score": it.score, + "sources": it.sources, + }, + }) + } + return docs, nil +} + +// ---------- RAG 问答工作流 ---------- + +var ChatService = &chatService{} + +type chatService struct{} + +// MaxHistoryRounds 携带进模型的历史对话轮数(每条消息算一条,含用户与助手) +const MaxHistoryRounds = 10 + +// Ask RAG 问答工作流:混合检索 → 组装提示(含引用编号)→ 对话模型流式生成。 +// history 需已包含最新一条用户问题;onCitations 在检索完成后先于流式输出回调;onDelta 接收增量文本,均可为 nil。 +func (s *chatService) Ask(ctx context.Context, datasetId int64, question string, history []*schema.Message, onCitations func([]domain.Citation), onDelta func(string)) (string, []domain.Citation, error) { + docs, err := s.retrieve(ctx, datasetId, question) + if err != nil { + return "", nil, err + } + citations := buildCitations(docs) + if onCitations != nil { + onCitations(citations) + } + + triples, err := KgRelationService.GraphEnhance(ctx, datasetId, question) + if err != nil { + g.Log().Warningf(ctx, "graph enhance failed: %v", err) + } + + defaultChatModel, _, err := SystemConfigService.GetSettings(ctx) + if err != nil { + return "", nil, err + } + if defaultChatModel <= 0 { + return "", nil, gerror.New("请先在设置中选择默认对话模型") + } + model, err := BuildChatModel(ctx, defaultChatModel) + if err != nil { + return "", nil, err + } + + msgs := make([]*schema.Message, 0, len(history)+1) + msgs = append(msgs, &schema.Message{Role: schema.System, Content: buildSystemPrompt(citations, triples)}) + if start := len(history) - MaxHistoryRounds*2; start > 0 { + history = history[start:] + } + msgs = append(msgs, history...) + + sr, err := model.Stream(ctx, msgs) + if err != nil { + return "", nil, gerror.Wrap(err, "调用对话模型失败") + } + defer sr.Close() + var full strings.Builder + for { + m, err := sr.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", nil, gerror.Wrap(err, "流式输出中断") + } + full.WriteString(m.Content) + if onDelta != nil { + onDelta(m.Content) + } + } + return full.String(), citations, nil +} + +// retrieve 构建数据集绑定的混合检索器并执行检索(无 embedding 配置时仅全文) +func (s *chatService) retrieve(ctx context.Context, datasetId int64, question string) ([]*schema.Document, error) { + var emb eembedding.Embedder + if cfgId, err := dao.Dataset.GetEmbeddingCfgId(ctx, datasetId); err == nil && cfgId > 0 { + if em, err := BuildEmbedder(ctx, cfgId); err == nil { + emb = em + } else { + g.Log().Warningf(ctx, "build embedder failed, retrieve fts only: %v", err) + } + } + return NewHybridRetriever(emb, datasetId).Retrieve(ctx, question) +} + +// buildCitations 从检索结果生成引用列表(编号从 1 开始,与提示词 [编号] 对应) +func buildCitations(docs []*schema.Document) []domain.Citation { + cits := make([]domain.Citation, 0, len(docs)) + for i, d := range docs { + c := domain.Citation{Index: i + 1, Content: d.Content} + if id, ok := d.MetaData["chunk_id"].(int64); ok { + c.ChunkId = id + } + if id, ok := d.MetaData["document_id"].(int64); ok { + c.DocumentId = id + } + if s, ok := d.MetaData["score"].(float64); ok { + c.Score = s + } + if srcs, ok := d.MetaData["sources"].([]string); ok { + c.Sources = srcs + } + cits = append(cits, c) + } + return cits +} + +// buildSystemPrompt 系统提示词:引用资料编号 + 检索片段 + 知识图谱三元组(M5 图增强) +func buildSystemPrompt(citations []domain.Citation, triples []string) string { + var sb strings.Builder + sb.WriteString("你是一个本地知识库助手。请仅根据以下资料回答用户问题;若资料不足以回答,请明确说明。") + sb.WriteString("回答引用资料时,在对应位置标注 [编号]。\n\n【资料】\n") + for _, c := range citations { + sb.WriteString(fmt.Sprintf("[%d] %s\n", c.Index, c.Content)) + } + if len(triples) > 0 { + sb.WriteString("\n【知识图谱】以下为与问题相关的实体关系,可辅助回答关系类问题:\n") + for _, t := range triples { + sb.WriteString(t + "\n") + } + } + return sb.String() +} + +// ---------- HTTP 辅助 ---------- + +func buildOpenAIMessages(input []*schema.Message) []openAIMessage { + out := make([]openAIMessage, 0, len(input)) + for _, m := range input { + if m == nil { + continue + } + role := string(m.Role) + if role == "" { + role = string(schema.User) + } + out = append(out, openAIMessage{Role: role, Content: m.Content}) + } + return out +} + +func postOpenAI(ctx context.Context, cfg *entity.ModelConfig, url string, payload any) ([]byte, error) { + body, err := doOpenAIRequest(ctx, cfg, url, payload) + if err != nil { + return nil, err + } + defer body.Close() + resp, err := io.ReadAll(body) + if err != nil { + return nil, err + } + return resp, nil +} + +func postOpenAIStream(ctx context.Context, cfg *entity.ModelConfig, url string, payload any) (io.ReadCloser, error) { + return doOpenAIRequest(ctx, cfg, url, payload) +} + +func doOpenAIRequest(ctx context.Context, cfg *entity.ModelConfig, url string, payload any) (io.ReadCloser, error) { + buf, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if cfg.ApiKey != "" { + req.Header.Set("Authorization", "Bearer "+cfg.ApiKey) + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + msg, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, gerror.Newf("模型接口 %s 返回 %d: %s", url, resp.StatusCode, strings.TrimSpace(string(msg))) + } + return resp.Body, nil +} diff --git a/kb/service/chunk_service.go b/kb/service/chunk_service.go new file mode 100644 index 0000000..1546efc --- /dev/null +++ b/kb/service/chunk_service.go @@ -0,0 +1,182 @@ +package service + +import ( + "context" + "strings" + "unicode/utf8" + + "rag-local/common" + "rag-local/kb/consts" + "rag-local/kb/dao" + "rag-local/kb/model/domain" + "rag-local/kb/model/entity" + + eembedding "github.com/cloudwego/eino/components/embedding" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +var ChunkService = &chunkService{} + +type chunkService struct{} + +// SplitText 文本分块:标题感知 + 固定大小回退,超长段落按句号/换行切分并保留重叠 +func (s *chunkService) SplitText(text string) []string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + + paragraphs := splitParagraphs(text) + var merged []string + var cur strings.Builder + curRunes := 0 + for _, p := range paragraphs { + pRunes := utf8.RuneCountInString(p) + if cur.Len() > 0 && curRunes+pRunes > consts.MaxChunkSize { + merged = append(merged, cur.String()) + cur.Reset() + curRunes = 0 + } + cur.WriteString(p) + cur.WriteString("\n\n") + curRunes += pRunes + 2 + } + if cur.Len() > 0 { + merged = append(merged, cur.String()) + } + + var chunks []string + for _, c := range merged { + if len(c) <= consts.MaxChunkSize { + chunks = append(chunks, strings.TrimSpace(c)) + continue + } + chunks = append(chunks, forceSplit(c)...) + } + return chunks +} + +// splitParagraphs 按空行/标题切段;# 标题行并入其后的段落(标题保留在段首,供检索回显) +func splitParagraphs(text string) []string { + lines := strings.Split(text, "\n") + var paras []string + var cur []string + flush := func() { + if len(cur) > 0 { + paras = append(paras, strings.Join(cur, "\n")) + cur = nil + } + } + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + flush() + continue + } + if isHeading(trimmed) && len(cur) > 0 { + flush() + } + cur = append(cur, trimmed) + } + flush() + return paras +} + +func isHeading(line string) bool { + return strings.HasPrefix(line, "#") || + strings.HasPrefix(line, "标题") || strings.HasPrefix(line, "第") && strings.Contains(line, "章") +} + +// forceSplit 超长文本按可读位置切分(rune 安全,避免切在 UTF-8 中间),重叠 overlap 字 +func forceSplit(text string) []string { + runes := []rune(text) + var result []string + for len(runes) > consts.MaxChunkSize { + limit := min(consts.MaxChunkSize, len(runes)) + cut := lastCutPoint(runes[:limit]) + if cut < consts.MaxChunkSize/2 { + cut = consts.MaxChunkSize + } + if chunk := strings.TrimSpace(string(runes[:cut])); chunk != "" { + result = append(result, chunk) + } + runes = runes[max(0, cut-consts.ChunkOverlap):] + } + if chunk := strings.TrimSpace(string(runes)); chunk != "" { + result = append(result, chunk) + } + return result +} + +// lastCutPoint 在 limit 内找最后一个可读切点(句号/问号/感叹号/分号/换行/英文标点),找不到返回 -1 +func lastCutPoint(runes []rune) int { + cut := -1 + for i, r := range runes { + switch r { + case '。', '!', '?', ';', '\n', '.', '!', '?', ';': + cut = i + 1 + } + } + return cut +} + +// List 分块列表 +func (s *chunkService) List(ctx context.Context, documentId int64, page, pageSize int) ([]*entity.Chunk, int, error) { + return dao.Chunk.ListByDocument(ctx, documentId, page, pageSize) +} + +// InsertAll 写入文档的全部分块;embedder 非空时批量向量化写入 vec0,否则仅写 FTS5 +func (s *chunkService) InsertAll(ctx context.Context, datasetId, documentId int64, chunks []string, embedder eembedding.Embedder) error { + for start := 0; start < len(chunks); start += consts.EmbedBatchSize { + end := min(start+consts.EmbedBatchSize, len(chunks)) + var vecs [][]float64 + if embedder != nil { + v, err := embedder.EmbedStrings(ctx, chunks[start:end]) + if err != nil { + return gerror.Wrap(err, "分块向量化失败") + } + vecs = v + } + for j, content := range chunks[start:end] { + vecJson := "" + if len(vecs) > 0 && j < len(vecs) { + vecJson = domain.VecJsonF64(vecs[j]) + } + if _, err := dao.Chunk.InsertWithVec(ctx, datasetId, documentId, start+j+1, content, "", "", vecJson, 0); err != nil { + return err + } + } + } + return dao.Document.UpdateFields(ctx, documentId, g.Map{ + "chunk_count": len(chunks), + "status": consts.DocumentStatusDone, + }) +} + +// Update 编辑分块文本:重新分词 FTS 索引,数据集绑定 embedding 配置时同步重新向量化 +func (s *chunkService) Update(ctx context.Context, id int64, content string) error { + chunk, err := dao.Chunk.GetOne(ctx, id) + if err != nil { + return err + } + if chunk == nil { + return gerror.New("分块不存在") + } + vecJson := "" + if cfgId, err := dao.Dataset.GetEmbeddingCfgId(ctx, chunk.DatasetId); err == nil && cfgId > 0 { + if em, err := BuildEmbedder(ctx, cfgId); err == nil { + if vecs, err := em.EmbedStrings(ctx, []string{content}); err != nil { + g.Log().Warningf(ctx, "re-embed chunk %d failed: %v", id, err) + } else if len(vecs) > 0 { + vecJson = domain.VecJsonF64(vecs[0]) + } + } else { + g.Log().Warningf(ctx, "build embedder failed: %v", err) + } + } + return dao.Chunk.UpdateContent(ctx, id, content, vecJson, common.Tokenize(content)) +} + +// DeleteByDocument 删除文档全部数据(chunk + vec + fts) +func (s *chunkService) DeleteByDocument(ctx context.Context, documentId int64) error { + return dao.Chunk.DeleteByDocument(ctx, documentId) +} diff --git a/kb/service/conversation_service.go b/kb/service/conversation_service.go new file mode 100644 index 0000000..eca29b3 --- /dev/null +++ b/kb/service/conversation_service.go @@ -0,0 +1,49 @@ +package service + +import ( + "context" + + "rag-local/kb/dao" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/errors/gerror" +) + +var ConversationService = &conversationService{} + +type conversationService struct{} + +func (s *conversationService) List(ctx context.Context) ([]*entity.Conversation, error) { + return dao.Conversation.List(ctx) +} + +func (s *conversationService) Save(ctx context.Context, c *entity.Conversation) (int64, error) { + if c.Id > 0 { + conv, err := dao.Conversation.GetOne(ctx, c.Id) + if err != nil { + return 0, err + } + if conv == nil { + return 0, gerror.New("会话不存在") + } + if c.Title != "" && c.Title != conv.Title { + if err := dao.Conversation.UpdateTitle(ctx, c.Id, c.Title); err != nil { + return 0, err + } + } + if c.DatasetId > 0 && c.DatasetId != conv.DatasetId { + if err := dao.Conversation.UpdateDataset(ctx, c.Id, c.DatasetId); err != nil { + return 0, err + } + } + return c.Id, nil + } + if c.DatasetId <= 0 { + return 0, gerror.New("请选择知识库数据集") + } + return dao.Conversation.Insert(ctx, c.DatasetId, c.Title) +} + +func (s *conversationService) Delete(ctx context.Context, id int64) error { + return dao.Conversation.Delete(ctx, id) +} diff --git a/kb/service/dataset_service.go b/kb/service/dataset_service.go new file mode 100644 index 0000000..0d4d56a --- /dev/null +++ b/kb/service/dataset_service.go @@ -0,0 +1,46 @@ +package service + +import ( + "context" + + "rag-local/kb/consts" + "rag-local/kb/dao" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +var DatasetService = &datasetService{} + +type datasetService struct{} + +func (s *datasetService) List(ctx context.Context) ([]*entity.Dataset, error) { + return dao.Dataset.List(ctx) +} + +func (s *datasetService) Save(ctx context.Context, m *entity.Dataset) (int64, error) { + if m.Status == 0 { + m.Status = 1 + } + if m.Id > 0 { + if err := dao.Dataset.Update(ctx, m); err != nil { + return 0, err + } + return m.Id, nil + } + return dao.Dataset.Insert(ctx, m) +} + +func (s *datasetService) Delete(ctx context.Context, id int64) error { + // 有文档的数据集不允许删除 + count, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDocument).Ctx(ctx). + Where("dataset_id", id).Count() + if err != nil { + return err + } + if count > 0 { + return gerror.New("数据集下存在文档,无法删除") + } + return dao.Dataset.Delete(ctx, id) +} diff --git a/kb/service/document_service.go b/kb/service/document_service.go new file mode 100644 index 0000000..f3e06fe --- /dev/null +++ b/kb/service/document_service.go @@ -0,0 +1,161 @@ +package service + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "rag-local/common" + "rag-local/kb/consts" + "rag-local/kb/dao" + "rag-local/kb/model/domain" + "rag-local/kb/model/entity" + + "github.com/gogf/gf/v2/errors/gerror" +) + +var DocumentService = &documentService{} + +type documentService struct{} + +// Upload 保存上传文件到 workspace/{datasetId}/{yyyymmdd}/{uuid}.ext,落库并提交解析任务 +func (s *documentService) Upload(ctx context.Context, datasetId int64, filename string, data []byte) (*entity.Document, error) { + dataset, err := dao.Dataset.GetOne(ctx, datasetId) + if err != nil { + return nil, err + } + if dataset == nil { + return nil, gerror.New("数据集不存在") + } + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filename)), ".") + if !isSupportedExt(ext) { + return nil, gerror.Newf("不支持的文档类型: %s(支持 %s)", ext, strings.Join(common.SupportedExts(), "/")) + } + if len(data) == 0 { + return nil, gerror.New("文件内容为空") + } + + relDir := filepath.Join(fmt.Sprintf("%d", datasetId), time.Now().Format("20060102")) + relPath := filepath.Join(relDir, fmt.Sprintf("%s.%s", common.RandomToken(16), ext)) + absPath := filepath.Join("workspace", relPath) + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(absPath, data, 0o644); err != nil { + return nil, err + } + + docId, err := dao.Document.Insert(ctx, &entity.Document{ + DatasetId: datasetId, + Filename: filename, + FilePath: relPath, + FileSize: int64(len(data)), + FileType: ext, + Status: consts.DocumentStatusPending, + }) + if err != nil { + _ = os.Remove(absPath) + return nil, err + } + if _, err := dao.ParseTask.Insert(ctx, docId, datasetId, consts.TaskTypeParse); err != nil { + _ = os.Remove(absPath) + return nil, err + } + return dao.Document.GetOne(ctx, docId) +} + +func isSupportedExt(ext string) bool { + for _, e := range common.SupportedExts() { + if e == ext { + return true + } + } + return false +} + +// List 文档列表 +func (s *documentService) List(ctx context.Context, datasetId int64, page, pageSize int) ([]*entity.Document, int, error) { + return dao.Document.List(ctx, datasetId, page, pageSize) +} + +// Reembed 文档全部分块重新向量化(分块文本不变,仅重算向量) +func (s *documentService) Reembed(ctx context.Context, id int64) error { + doc, err := dao.Document.GetOne(ctx, id) + if err != nil { + return err + } + if doc == nil { + return gerror.New("文档不存在") + } + cfgId, err := dao.Dataset.GetEmbeddingCfgId(ctx, doc.DatasetId) + if err != nil { + return err + } + if cfgId <= 0 { + return gerror.New("数据集未绑定向量模型,无法向量化") + } + em, err := BuildEmbedder(ctx, cfgId) + if err != nil { + return err + } + chunks, _, err := dao.Chunk.ListByDocument(ctx, id, 1, 100000) + if err != nil { + return err + } + for start := 0; start < len(chunks); start += consts.EmbedBatchSize { + end := min(start+consts.EmbedBatchSize, len(chunks)) + texts := make([]string, 0, end-start) + for _, c := range chunks[start:end] { + texts = append(texts, c.Content) + } + vecs, err := em.EmbedStrings(ctx, texts) + if err != nil { + return gerror.Wrap(err, "向量化失败") + } + for j, c := range chunks[start:end] { + if err := dao.Chunk.UpdateVec(ctx, c.Id, domain.VecJsonF64(vecs[j])); err != nil { + return err + } + } + } + return nil +} + +// Delete 删除文档:先删文件与索引数据,再删记录 +func (s *documentService) Delete(ctx context.Context, id int64) error { + doc, err := dao.Document.GetOne(ctx, id) + if err != nil { + return err + } + if doc == nil { + return nil + } + // 先取分块 id 清理知识图谱数据(分块删除后无法再映射) + chunks, _, err := dao.Chunk.ListByDocument(ctx, id, 1, 100000) + if err != nil { + return err + } + chunkIds := make([]int64, 0, len(chunks)) + for _, c := range chunks { + chunkIds = append(chunkIds, c.Id) + } + if err := dao.KgRelation.DeleteByChunkIds(ctx, chunkIds); err != nil { + return err + } + if err := dao.KgEntity.DeleteByChunkIds(ctx, chunkIds); err != nil { + return err + } + if err := ChunkService.DeleteByDocument(ctx, id); err != nil { + return err + } + if err := dao.ParseTask.DeleteByDocument(ctx, id); err != nil { + return err + } + if doc.FilePath != "" { + _ = os.Remove(filepath.Join("workspace", doc.FilePath)) + } + return dao.Document.Delete(ctx, id) +} diff --git a/kb/service/kg_entity_service.go b/kb/service/kg_entity_service.go new file mode 100644 index 0000000..71b42a8 --- /dev/null +++ b/kb/service/kg_entity_service.go @@ -0,0 +1,117 @@ +package service + +import ( + "context" + "encoding/json" + "strings" + + "rag-local/kb/dao" + "rag-local/kb/model/entity" + + "github.com/cloudwego/eino/schema" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +var KgEntityService = &kgEntityService{} + +type kgEntityService struct{} + +type kgEntityItem struct { + Name string `json:"name"` + Type string `json:"type"` +} + +type kgRelationItem struct { + Head string `json:"head"` + Relation string `json:"relation"` + Tail string `json:"tail"` +} + +type kgExtractResult struct { + Entities []kgEntityItem `json:"entities"` + Relations []kgRelationItem `json:"relations"` +} + +// ExtractDocument 对文档全部新分块做 LLM 抽取(挂在解析流水线分块落库之后)。 +// 每个分块一次调用,任何失败只记日志,不阻断解析流水线;未配置默认对话模型时直接跳过。 +func (s *kgEntityService) ExtractDocument(ctx context.Context, datasetId, documentId int64) error { + chunks, _, err := dao.Chunk.ListByDocument(ctx, documentId, 1, 100000) + if err != nil { + return err + } + model, err := s.buildModel(ctx) + if err != nil { + g.Log().Infof(ctx, "kg extract skipped: %v", err) + return nil + } + for _, c := range chunks { + if err := s.extractChunk(ctx, model, datasetId, c); err != nil { + g.Log().Warningf(ctx, "kg extract chunk %d failed: %v", c.Id, err) + } + } + return nil +} + +func (s *kgEntityService) buildModel(ctx context.Context) (*OpenAIChatModel, error) { + defaultChatModel, _, err := SystemConfigService.GetSettings(ctx) + if err != nil { + return nil, err + } + if defaultChatModel <= 0 { + return nil, gerror.New("未配置默认对话模型") + } + return BuildChatModel(ctx, defaultChatModel) +} + +func (s *kgEntityService) extractChunk(ctx context.Context, model *OpenAIChatModel, datasetId int64, chunk *entity.Chunk) error { + msgs := []*schema.Message{ + {Role: schema.System, Content: "你是知识抽取助手。从文档片段中抽取实体(人名、组织、地名、产品等专有名词)及实体间的关系(动词或介词短语)。只输出 JSON,不要 markdown 代码块或任何解释,格式:{\"entities\":[{\"name\":\"实体名\",\"type\":\"类型\"}],\"relations\":[{\"head\":\"主体\",\"relation\":\"关系\",\"tail\":\"客体\"}]}"}, + {Role: schema.User, Content: "文档片段:\n" + chunk.Content}, + } + resp, err := model.Generate(ctx, msgs) + if err != nil { + return err + } + data, err := parseKgJSON(resp.Content) + if err != nil { + return err + } + for _, e := range data.Entities { + name := strings.TrimSpace(e.Name) + if name == "" { + continue + } + if err := dao.KgEntity.Upsert(ctx, datasetId, chunk.Id, name, strings.TrimSpace(e.Type)); err != nil { + return err + } + } + for _, r := range data.Relations { + head, relation, tail := strings.TrimSpace(r.Head), strings.TrimSpace(r.Relation), strings.TrimSpace(r.Tail) + if head == "" || relation == "" || tail == "" || head == tail { + continue + } + if err := dao.KgRelation.Insert(ctx, datasetId, chunk.Id, head, relation, tail); err != nil { + return err + } + } + return nil +} + +// parseKgJSON 解析模型输出的 JSON,容忍 ```json 代码块包裹 +func parseKgJSON(content string) (*kgExtractResult, error) { + content = strings.TrimSpace(content) + content = strings.TrimPrefix(content, "```json") + content = strings.TrimPrefix(content, "```") + content = strings.TrimSuffix(content, "```") + content = strings.TrimSpace(content) + var out kgExtractResult + if err := json.Unmarshal([]byte(content), &out); err != nil { + return nil, gerror.Wrap(err, "解析抽取 JSON 失败") + } + return &out, nil +} + +func (s *kgEntityService) List(ctx context.Context, datasetId int64, page, pageSize int) ([]*entity.KgEntity, int, error) { + return dao.KgEntity.List(ctx, datasetId, page, pageSize) +} diff --git a/kb/service/kg_relation_service.go b/kb/service/kg_relation_service.go new file mode 100644 index 0000000..76b6b48 --- /dev/null +++ b/kb/service/kg_relation_service.go @@ -0,0 +1,90 @@ +package service + +import ( + "context" + "fmt" + "sort" + "strings" + + "rag-local/common" + "rag-local/kb/dao" + "rag-local/kb/model/entity" +) + +var KgRelationService = &kgRelationService{} + +type kgRelationService struct{} + +const ( + kgLinkTopN = 3 // 实体链接命中前 N 个实体 + kgNeighborLimit = 20 // 一跳邻居三元组上限 +) + +// GraphEnhance 图增强检索:问题分词与实体名匹配(Top3)→ 一跳邻居三元组 → 格式化文本(供注入提示词) +func (s *kgRelationService) GraphEnhance(ctx context.Context, datasetId int64, question string) ([]string, error) { + names, err := dao.KgEntity.ListNames(ctx, datasetId) + if err != nil { + return nil, err + } + linked := linkEntities(question, names, kgLinkTopN) + if len(linked) == 0 { + return nil, nil + } + triples, err := dao.KgRelation.Neighbors(ctx, datasetId, linked, kgNeighborLimit) + if err != nil { + return nil, err + } + out := make([]string, 0, len(triples)) + for _, t := range triples { + out = append(out, fmt.Sprintf("%s -%s-> %s", t.Head, t.Relation, t.Tail)) + } + return out, nil +} + +// linkEntities 问题分词后按 token 命中实体名的个数打分,取前 topN;同分按名称长度优先 +func linkEntities(question string, names []string, topN int) []string { + tokens := strings.Fields(common.Tokenize(question)) + if len(tokens) == 0 { + return nil + } + type scored struct { + name string + score int + } + var hits []scored + for _, n := range names { + if n == "" { + continue + } + score := 0 + if strings.Contains(question, n) { + score += 5 // 问题中出现完整实体名,强相关 + } + for _, t := range tokens { + if strings.Contains(n, t) { + score += len([]rune(t)) // 命中 token 越长相关性越高 + } + } + if score > 0 { + hits = append(hits, scored{name: n, score: score}) + } + } + sort.Slice(hits, func(i, j int) bool { + if hits[i].score != hits[j].score { + return hits[i].score > hits[j].score + } + return len(hits[i].name) > len(hits[j].name) + }) + if len(hits) > topN { + hits = hits[:topN] + } + out := make([]string, 0, len(hits)) + for _, h := range hits { + out = append(out, h.name) + } + return out +} + +func (s *kgRelationService) List(ctx context.Context, datasetId int64, page, pageSize int) ([]*entity.KgRelation, int, error) { + return dao.KgRelation.List(ctx, datasetId, page, pageSize) +} diff --git a/kb/service/message_service.go b/kb/service/message_service.go new file mode 100644 index 0000000..e3378e0 --- /dev/null +++ b/kb/service/message_service.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + "encoding/json" + + "rag-local/kb/dao" + "rag-local/kb/model/domain" + "rag-local/kb/model/entity" + + "github.com/cloudwego/eino/schema" + "github.com/gogf/gf/v2/errors/gerror" +) + +var MessageService = &messageService{} + +type messageService struct{} + +func (s *messageService) List(ctx context.Context, conversationId int64) ([]*entity.Message, error) { + return dao.Message.List(ctx, conversationId) +} + +// Chat RAG 问答:会话解析 → 用户消息落库 → 工作流流式生成 → 助手消息+引用落库。 +// onCitations 在检索完成后回调(先于流式输出);onDelta 接收模型增量文本。 +func (s *messageService) Chat(ctx context.Context, conversationId, datasetId int64, question string, onCitations func([]domain.Citation, int64), onDelta func(string)) (string, []domain.Citation, int64, error) { + if conversationId <= 0 { + title := question + if r := []rune(title); len(r) > 20 { + title = string(r[:20]) + } + id, err := dao.Conversation.Insert(ctx, datasetId, title) + if err != nil { + return "", nil, 0, err + } + conversationId = id + } + conv, err := dao.Conversation.GetOne(ctx, conversationId) + if err != nil { + return "", nil, 0, err + } + if conv == nil { + return "", nil, 0, gerror.New("会话不存在") + } + if conv.DatasetId == 0 && datasetId > 0 { + _ = dao.Conversation.UpdateDataset(ctx, conversationId, datasetId) + conv.DatasetId = datasetId + } + if conv.DatasetId > 0 { + datasetId = conv.DatasetId + } + if datasetId <= 0 { + return "", nil, 0, gerror.New("请选择知识库数据集") + } + + if _, err := dao.Message.Insert(ctx, conversationId, "user", question, ""); err != nil { + return "", nil, 0, err + } + + history, err := dao.Message.List(ctx, conversationId) + if err != nil { + return "", nil, 0, err + } + messages := make([]*schema.Message, 0, len(history)) + for _, m := range history { + messages = append(messages, &schema.Message{Role: schema.RoleType(m.Role), Content: m.Content}) + } + + answer, citations, err := ChatService.Ask(ctx, datasetId, question, messages, + func(c []domain.Citation) { + if onCitations != nil { + onCitations(c, conversationId) + } + }, onDelta) + if err != nil { + return "", nil, 0, err + } + + citJson, _ := json.Marshal(citations) + if _, err := dao.Message.Insert(ctx, conversationId, "assistant", answer, string(citJson)); err != nil { + return "", nil, 0, err + } + return answer, citations, conversationId, nil +} diff --git a/kb/service/model_config_service.go b/kb/service/model_config_service.go new file mode 100644 index 0000000..a5b63bc --- /dev/null +++ b/kb/service/model_config_service.go @@ -0,0 +1,87 @@ +package service + +import ( + "context" + + "rag-local/kb/consts" + "rag-local/kb/dao" + "rag-local/kb/model/entity" + + "github.com/cloudwego/eino/schema" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +var ModelConfigService = &modelConfigService{} + +type modelConfigService struct{} + +func (s *modelConfigService) List(ctx context.Context, modelType string) ([]*entity.ModelConfig, error) { + return dao.ModelConfig.List(ctx, modelType) +} + +func (s *modelConfigService) Save(ctx context.Context, m *entity.ModelConfig) (int64, error) { + if m.Dimension <= 0 { + m.Dimension = consts.DefaultEmbeddingDim + } + if m.Id > 0 { + if err := dao.ModelConfig.Update(ctx, m); err != nil { + return 0, err + } + return m.Id, nil + } + return dao.ModelConfig.Insert(ctx, m) +} + +// Test 连通性测试:对话模型发一次 ping,向量模型嵌入一次,任一异常即失败 +func (s *modelConfigService) Test(ctx context.Context, id int64) error { + cfg, err := dao.ModelConfig.GetOne(ctx, id) + if err != nil { + return err + } + if cfg == nil { + return gerror.New("模型配置不存在") + } + switch cfg.ModelType { + case consts.ModelTypeChat: + model, err := BuildChatModel(ctx, id) + if err != nil { + return err + } + msg, err := model.Generate(ctx, []*schema.Message{{Role: schema.User, Content: "ping"}}) + if err != nil { + return gerror.Wrap(err, "对话接口调用失败") + } + if msg == nil { + return gerror.New("对话接口返回空") + } + case consts.ModelTypeEmbedding: + em, err := BuildEmbedder(ctx, id) + if err != nil { + return err + } + vecs, err := em.EmbedStrings(ctx, []string{"ping"}) + if err != nil { + return gerror.Wrap(err, "向量接口调用失败") + } + if len(vecs) == 0 || len(vecs[0]) == 0 { + return gerror.New("向量接口返回空数据") + } + default: + return gerror.New("未知模型类型") + } + return nil +} + +func (s *modelConfigService) Delete(ctx context.Context, id int64) error { + // 被数据集绑定的 embedding 配置不允许删除 + count, err := g.DB(consts.DbGroupDefault).Model(consts.TableNameDataset).Ctx(ctx). + Where("embedding_cfg_id", id).Count() + if err != nil { + return err + } + if count > 0 { + return gerror.New("该模型配置正被数据集使用,无法删除") + } + return dao.ModelConfig.Delete(ctx, id) +} diff --git a/kb/service/parse_task_service.go b/kb/service/parse_task_service.go new file mode 100644 index 0000000..f246a99 --- /dev/null +++ b/kb/service/parse_task_service.go @@ -0,0 +1,130 @@ +package service + +import ( + "context" + "path/filepath" + "time" + + "rag-local/common" + "rag-local/kb/consts" + "rag-local/kb/dao" + "rag-local/kb/model/entity" + + eembedding "github.com/cloudwego/eino/components/embedding" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +var ParseTaskService = &parseTaskService{} + +type parseTaskService struct{} + +// StartParsePoller 启动任务轮询:单 goroutine 串行消费待处理任务(与 video-factory StartVideoPoller 同模式) +func (s *parseTaskService) StartParsePoller(ctx context.Context) { + go func() { + g.Log().Info(ctx, "parse task poller started") + for { + select { + case <-ctx.Done(): + return + case <-time.After(consts.ParsePollIntervalSeconds * time.Second): + s.processOne(ctx) + } + } + }() +} + +// processOne 处理一个待处理任务:解析 → 分块 → 落库(向量化在 M3 接入) +func (s *parseTaskService) processOne(ctx context.Context) { + task, err := dao.ParseTask.NextPending(ctx) + if err != nil { + g.Log().Errorf(ctx, "next parse task failed: %v", err) + return + } + if task == nil { + return + } + if err := dao.ParseTask.UpdateStatus(ctx, task.Id, consts.TaskStatusRunning, ""); err != nil { + g.Log().Errorf(ctx, "mark task running failed: %v", err) + return + } + + doc, err := dao.Document.GetOne(ctx, task.DocumentId) + if err != nil { + s.fail(ctx, task, "读取文档失败: "+err.Error()) + return + } + if doc == nil { + s.fail(ctx, task, "文档不存在") + return + } + if err := dao.Document.UpdateFields(ctx, doc.Id, g.Map{"status": consts.DocumentStatusParsing}); err != nil { + s.fail(ctx, task, "更新文档状态失败: "+err.Error()) + return + } + + text, err := common.ParseFile(filepath.Join("workspace", doc.FilePath)) + if err != nil { + s.fail(ctx, task, "解析文件失败: "+err.Error()) + return + } + chunks := ChunkService.SplitText(text) + // 数据集绑定 embedding 配置时构建向量模型,无配置降级为仅全文索引 + var embedder eembedding.Embedder + if cfgId, err := dao.Dataset.GetEmbeddingCfgId(ctx, task.DatasetId); err == nil && cfgId > 0 { + if em, err := BuildEmbedder(ctx, cfgId); err == nil { + embedder = em + if dim := em.Dim(); dim != g.Cfg().MustGet(ctx, "vector.dim", consts.DefaultEmbeddingDim).Int() { + g.Log().Warningf(ctx, "embedding 维度 %d 与 vec0 表维度不一致,请确认 vector.dim 配置", dim) + } + } else { + g.Log().Warningf(ctx, "build embedder failed, fallback to fts only: %v", err) + } + } + if err := ChunkService.InsertAll(ctx, task.DatasetId, doc.Id, chunks, embedder); err != nil { + s.fail(ctx, task, "写入分块失败: "+err.Error()) + return + } + // 第 3.5 步:知识图谱抽取(失败不阻断流水线) + if err := KgEntityService.ExtractDocument(ctx, task.DatasetId, doc.Id); err != nil { + g.Log().Warningf(ctx, "kg extract skipped for doc %d: %v", doc.Id, err) + } + if err := dao.ParseTask.UpdateStatus(ctx, task.Id, consts.TaskStatusDone, ""); err != nil { + g.Log().Errorf(ctx, "mark task done failed: %v", err) + } +} + +func (s *parseTaskService) fail(ctx context.Context, task *entity.ParseTask, msg string) { + _ = dao.ParseTask.UpdateStatus(ctx, task.Id, consts.TaskStatusFailed, msg) + _ = dao.Document.UpdateFields(ctx, task.DocumentId, g.Map{ + "status": consts.DocumentStatusFailed, + "error_msg": msg, + }) + g.Log().Errorf(ctx, "parse task %d failed: %s", task.Id, msg) +} + +// List 任务列表 +func (s *parseTaskService) List(ctx context.Context, page, pageSize int) ([]*entity.ParseTask, int, error) { + return dao.ParseTask.List(ctx, page, pageSize) +} + +// Retry 失败任务重置为待处理(文档状态同步重置) +func (s *parseTaskService) Retry(ctx context.Context, id int64) error { + task, err := dao.ParseTask.GetOne(ctx, id) + if err != nil { + return err + } + if task == nil { + return gerror.New("任务不存在") + } + if task.Status != consts.TaskStatusFailed { + return gerror.New("仅失败任务可重试") + } + if err := dao.ParseTask.UpdateStatus(ctx, id, consts.TaskStatusPending, ""); err != nil { + return err + } + return dao.Document.UpdateFields(ctx, task.DocumentId, g.Map{ + "status": consts.DocumentStatusPending, + "error_msg": "", + }) +} diff --git a/kb/service/system_config_service.go b/kb/service/system_config_service.go new file mode 100644 index 0000000..81bcb7b --- /dev/null +++ b/kb/service/system_config_service.go @@ -0,0 +1,100 @@ +package service + +import ( + "context" + "time" + + "rag-local/common" + "rag-local/kb/consts" + "rag-local/kb/dao" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gcache" + "github.com/gogf/gf/v2/util/gconv" +) + +var SystemConfigService = &systemConfigService{} + +type systemConfigService struct{} + +func init() { + // 注入指纹校验函数,避免 common → service 循环依赖 + common.CheckTokenFingerprint = SystemConfigService.CheckTokenFingerprint +} + +// EnsureAccessToken 首次启动生成访问令牌并写入配置;已有则复用。返回当前令牌 +func (s *systemConfigService) EnsureAccessToken(ctx context.Context) (string, error) { + token, err := dao.SystemConfig.Get(ctx, consts.CfgKeyAccessToken) + if err != nil { + return "", err + } + if token == "" { + token = common.RandomToken(16) + if err := dao.SystemConfig.Set(ctx, consts.CfgKeyAccessToken, token); err != nil { + return "", err + } + } + return token, nil +} + +func (s *systemConfigService) Login(ctx context.Context, token string) (string, error) { + cur, err := dao.SystemConfig.Get(ctx, consts.CfgKeyAccessToken) + if err != nil { + return "", err + } + if cur == "" || token != cur { + return "", gerror.New("访问令牌错误") + } + return common.SignToken("owner", common.TokenFingerprint(cur), common.TokenExpireSeconds) +} + +// GetToken 当前访问令牌(设置页展示用) +func (s *systemConfigService) GetToken(ctx context.Context) (string, error) { + return dao.SystemConfig.Get(ctx, consts.CfgKeyAccessToken) +} + +// TokenFingerprint 当前令牌指纹(带短缓存,鉴权路径避免频繁查库) +func (s *systemConfigService) TokenFingerprint(ctx context.Context) string { + if v, err := gcache.Get(ctx, "access_token_fp"); err == nil && !v.IsNil() { + return v.String() + } + token, err := dao.SystemConfig.Get(ctx, consts.CfgKeyAccessToken) + if err != nil || token == "" { + return "" + } + fp := common.TokenFingerprint(token) + _ = gcache.Set(ctx, "access_token_fp", fp, 10*time.Second) + return fp +} + +func (s *systemConfigService) CheckTokenFingerprint(ctx context.Context, fp string) bool { + return fp != "" && fp == s.TokenFingerprint(ctx) +} + +func (s *systemConfigService) RegenerateToken(ctx context.Context) (string, error) { + token := common.RandomToken(16) + if err := dao.SystemConfig.Set(ctx, consts.CfgKeyAccessToken, token); err != nil { + return "", err + } + _, _ = gcache.Remove(ctx, "access_token_fp") + return token, nil +} + +func (s *systemConfigService) GetSettings(ctx context.Context) (defaultChatModel, defaultDataset int64, err error) { + v1, err := dao.SystemConfig.Get(ctx, consts.CfgKeyDefaultChatModel) + if err != nil { + return + } + v2, err := dao.SystemConfig.Get(ctx, consts.CfgKeyDefaultDataset) + if err != nil { + return + } + return gconv.Int64(v1), gconv.Int64(v2), nil +} + +func (s *systemConfigService) UpdateSettings(ctx context.Context, defaultChatModel, defaultDataset int64) error { + if err := dao.SystemConfig.Set(ctx, consts.CfgKeyDefaultChatModel, gconv.String(defaultChatModel)); err != nil { + return err + } + return dao.SystemConfig.Set(ctx, consts.CfgKeyDefaultDataset, gconv.String(defaultDataset)) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..90ee0d1 --- /dev/null +++ b/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + commonHttp "rag-local/common" + "rag-local/kb/controller" + "rag-local/kb/service" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + _ "modernc.org/sqlite/vec" +) + +func main() { + // ==================== API 路由(通过 RouteRegister 自动注册) ==================== + commonHttp.RouteRegister([]interface{}{ + controller.SystemConfig, + controller.ModelConfig, + controller.Dataset, + controller.Document, + controller.Chunk, + controller.ParseTask, + controller.Conversation, + controller.Message, + controller.KgEntity, + controller.KgRelation, + }) + + // ==================== Workspace 文件服务(源文件访问,路径穿越防护) ==================== + commonHttp.Httpserver.BindHandler("/workspace/*", func(r *ghttp.Request) { + relPath := strings.TrimPrefix(r.URL.Path, "/workspace/") + if relPath == "" || strings.Contains(relPath, "..") { + r.Response.WriteStatus(403) + return + } + filePath := filepath.Join("workspace", relPath) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + r.Response.WriteStatus(404) + return + } + r.Response.ServeFile(filePath) + }) + + // ==================== 前端静态资源服务(SPA,前后端合并部署) ==================== + if st, err := os.Stat("ui-src/dist"); err == nil && st.IsDir() { + commonHttp.Httpserver.BindHandler("/*", func(r *ghttp.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + if strings.Contains(path, "..") { + r.Response.WriteStatus(404) + return + } + if path == "" || path == "index.html" { + path = "index.html" + } + filePath := filepath.Join("ui-src/dist", path) + if st, err := os.Stat(filePath); err == nil && !st.IsDir() { + r.Response.ServeFile(filePath) + return + } + r.Response.WriteStatus(404) + }) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // ==================== 解析任务轮询(文档流水线消费端) ==================== + service.ParseTaskService.StartParsePoller(ctx) + + // ==================== 访问令牌(首次启动生成,打印到控制台) ==================== + token, err := service.SystemConfigService.EnsureAccessToken(ctx) + if err != nil { + g.Log().Fatal(ctx, "ensure access token failed: %v", err) + } + g.Log().Infof(ctx, "============================================") + g.Log().Infof(ctx, "访问令牌(登录用): %s", token) + g.Log().Infof(ctx, "请在登录页输入上述令牌") + g.Log().Infof(ctx, "============================================") + + g.Log().Info(ctx, "service started on :8080") + + <-ctx.Done() + g.Log().Info(ctx, "shutting down...") + time.Sleep(3 * time.Second) + g.Log().Info(ctx, "bye") +} diff --git a/ui-src/.gitignore b/ui-src/.gitignore new file mode 100644 index 0000000..f633501 --- /dev/null +++ b/ui-src/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env.local diff --git a/ui-src/index.html b/ui-src/index.html new file mode 100644 index 0000000..2175ba9 --- /dev/null +++ b/ui-src/index.html @@ -0,0 +1,12 @@ + + + + + + 本地知识库 + + +
+ + + diff --git a/ui-src/package-lock.json b/ui-src/package-lock.json new file mode 100644 index 0000000..836bc2a --- /dev/null +++ b/ui-src/package-lock.json @@ -0,0 +1,1862 @@ +{ + "name": "rag-local-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rag-local-ui", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.6.0", + "element-plus": "^2.5.0", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.3", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz", + "integrity": "sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.5" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.9.tgz", + "integrity": "sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/ui-src/package.json b/ui-src/package.json new file mode 100644 index 0000000..ae2b69d --- /dev/null +++ b/ui-src/package.json @@ -0,0 +1,23 @@ +{ + "name": "rag-local-ui", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.6.0", + "element-plus": "^2.5.0", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } +} diff --git a/ui-src/src/App.vue b/ui-src/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/ui-src/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/ui-src/src/api/auth.js b/ui-src/src/api/auth.js new file mode 100644 index 0000000..994c4f7 --- /dev/null +++ b/ui-src/src/api/auth.js @@ -0,0 +1,21 @@ +import request from './request.js' + +export function login(data) { + return request.post('/system-config/login', data) +} + +export function getSystemConfig() { + return request.get('/system-config') +} + +export function updateSystemConfig(data) { + return request.put('/system-config', data) +} + +export function regenerateToken() { + return request.post('/system-config/regenerate-token') +} + +export function getToken() { + return request.get('/system-config/token') +} diff --git a/ui-src/src/api/chat.js b/ui-src/src/api/chat.js new file mode 100644 index 0000000..5e0f33c --- /dev/null +++ b/ui-src/src/api/chat.js @@ -0,0 +1,73 @@ +import request from './request.js' + +export function listConversations() { + return request.get('/conversation/list') +} + +export function saveConversation(data) { + return request.post('/conversation/save', data) +} + +export function deleteConversation(id) { + return request.post('/conversation/delete', { id }) +} + +export function listMessages(conversationId) { + return request.get('/message/list', { params: { conversation_id: conversationId } }) +} + +// streamChat RAG 问答 SSE:handlers.onCitations / onDelta / onDone / onError +export async function streamChat(payload, handlers) { + const token = localStorage.getItem('token') + let resp + try { + resp = await fetch('/message/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token ? 'Bearer ' + token : '' + }, + body: JSON.stringify(payload) + }) + } catch (e) { + handlers.onError(new Error('网络错误,请检查服务是否启动')) + return + } + if (!resp.ok || !resp.body) { + handlers.onError(new Error('请求失败:' + resp.status)) + return + } + const reader = resp.body.getReader() + const decoder = new TextDecoder() + let buf = '' + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + let idx + while ((idx = buf.indexOf('\n\n')) >= 0) { + const block = buf.slice(0, idx) + buf = buf.slice(idx + 2) + for (const line of block.split('\n')) { + if (!line.startsWith('data: ')) continue + const data = line.slice(6).trim() + if (!data || data === '[DONE]') continue + let obj + try { + obj = JSON.parse(data) + } catch { + continue + } + if (obj.citations !== undefined) handlers.onCitations(obj) + else if (obj.content !== undefined) handlers.onDelta(obj.content) + else if (obj.status === 'ok') handlers.onDone() + else if (obj.message) handlers.onError(new Error(obj.message)) + } + } + } + handlers.onDone() + } catch (e) { + handlers.onError(e) + } +} diff --git a/ui-src/src/api/chunk.js b/ui-src/src/api/chunk.js new file mode 100644 index 0000000..97b2b54 --- /dev/null +++ b/ui-src/src/api/chunk.js @@ -0,0 +1,9 @@ +import request from './request.js' + +export function listChunks(params) { + return request.get('/chunk/list', { params }) +} + +export function updateChunk(data) { + return request.post('/chunk/update', data) +} diff --git a/ui-src/src/api/dataset.js b/ui-src/src/api/dataset.js new file mode 100644 index 0000000..12f05e3 --- /dev/null +++ b/ui-src/src/api/dataset.js @@ -0,0 +1,13 @@ +import request from './request.js' + +export function listDatasets() { + return request.get('/dataset/list') +} + +export function saveDataset(data) { + return request.post('/dataset/save', data) +} + +export function deleteDataset(id) { + return request.post('/dataset/delete', { id }) +} diff --git a/ui-src/src/api/document.js b/ui-src/src/api/document.js new file mode 100644 index 0000000..dfee1c8 --- /dev/null +++ b/ui-src/src/api/document.js @@ -0,0 +1,21 @@ +import request from './request.js' + +export function listDocuments(params) { + return request.get('/document/list', { params }) +} + +export function uploadDocument(formData) { + return request.post('/document/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) +} + +export function deleteDocument(id) { + return request.post('/document/delete', { id }) +} + +export function reembedDocument(id) { + return request.post('/document/reembed', { id }) +} + +export function retryParseTask(id) { + return request.post('/parse-task/retry', { id }) +} diff --git a/ui-src/src/api/kg.js b/ui-src/src/api/kg.js new file mode 100644 index 0000000..b58bed7 --- /dev/null +++ b/ui-src/src/api/kg.js @@ -0,0 +1,9 @@ +import request from './request.js' + +export function listEntities(params) { + return request.get('/kg-entity/list', { params }) +} + +export function listRelations(params) { + return request.get('/kg-relation/list', { params }) +} diff --git a/ui-src/src/api/model_config.js b/ui-src/src/api/model_config.js new file mode 100644 index 0000000..cbfd038 --- /dev/null +++ b/ui-src/src/api/model_config.js @@ -0,0 +1,17 @@ +import request from './request.js' + +export function listModelConfigs(modelType) { + return request.get('/model-config/list', { params: { model_type: modelType } }) +} + +export function saveModelConfig(data) { + return request.post('/model-config/save', data) +} + +export function deleteModelConfig(id) { + return request.post('/model-config/delete', { id }) +} + +export function testModelConfig(id) { + return request.post('/model-config/test', { id }) +} diff --git a/ui-src/src/api/request.js b/ui-src/src/api/request.js new file mode 100644 index 0000000..3cb301c --- /dev/null +++ b/ui-src/src/api/request.js @@ -0,0 +1,43 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' + +const request = axios.create({ + timeout: 300000 +}) + +request.interceptors.request.use(config => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = 'Bearer ' + token + } + return config +}) + +function redirectLogin() { + localStorage.removeItem('token') + window.location.href = '/#/login' +} + +request.interceptors.response.use( + response => { + const data = response.data + if (data.code !== 0) { + if (data.code === 401) { + redirectLogin() + return Promise.reject(new Error(data.message)) + } + ElMessage.error(data.message || '请求失败') + return Promise.reject(new Error(data.message)) + } + return data.data + }, + error => { + if (error.response?.status === 401) { + redirectLogin() + } + ElMessage.error(error.message || '网络错误') + return Promise.reject(error) + } +) + +export default request diff --git a/ui-src/src/main.js b/ui-src/src/main.js new file mode 100644 index 0000000..0c96eb4 --- /dev/null +++ b/ui-src/src/main.js @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(createPinia()) +app.use(ElementPlus, { locale: zhCn }) +app.use(router) +app.mount('#app') diff --git a/ui-src/src/router/index.js b/ui-src/src/router/index.js new file mode 100644 index 0000000..ccb27da --- /dev/null +++ b/ui-src/src/router/index.js @@ -0,0 +1,69 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth.js' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('../views/Login.vue') + }, + { + path: '/', + component: () => import('../views/Layout.vue'), + redirect: '/datasets', + children: [ + { + path: 'datasets', + name: 'DatasetList', + meta: { title: '数据集' }, + component: () => import('../views/DatasetList.vue') + }, + { + path: 'datasets/:id', + name: 'DatasetDetail', + meta: { title: '数据集详情' }, + component: () => import('../views/DatasetDetail.vue') + }, + { + path: 'chat', + name: 'Chat', + meta: { title: '问答' }, + component: () => import('../views/Chat.vue') + }, + { + path: 'kg', + name: 'KgGraph', + meta: { title: '知识图谱' }, + component: () => import('../views/KgGraph.vue') + }, + { + path: 'settings', + name: 'Settings', + meta: { title: '设置' }, + component: () => import('../views/Settings.vue') + } + ] + } +] + +const router = createRouter({ + // hash 路由:前后端合并部署后 SPA 路由与后端 API 前缀重叠, + // history 模式刷新会命中 API 路由;hash 模式页面始终请求 /,由后端静态服务返回 index.html + history: createWebHashHistory(), + routes +}) + +router.beforeEach((to, from, next) => { + const auth = useAuthStore() + if (to.path !== '/login' && !auth.isLoggedIn) { + next('/login') + return + } + if (to.path === '/login' && auth.isLoggedIn) { + next('/') + return + } + next() +}) + +export default router diff --git a/ui-src/src/stores/auth.js b/ui-src/src/stores/auth.js new file mode 100644 index 0000000..e30842a --- /dev/null +++ b/ui-src/src/stores/auth.js @@ -0,0 +1,23 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { login } from '../api/auth.js' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('token') || '') + + const isLoggedIn = computed(() => !!token.value) + + async function doLogin(accessToken) { + const res = await login({ token: accessToken }) + token.value = res.token + localStorage.setItem('token', res.token) + return res + } + + function logout() { + token.value = '' + localStorage.removeItem('token') + } + + return { token, isLoggedIn, doLogin, logout } +}) diff --git a/ui-src/src/views/Chat.vue b/ui-src/src/views/Chat.vue new file mode 100644 index 0000000..5a0e381 --- /dev/null +++ b/ui-src/src/views/Chat.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/ui-src/src/views/DatasetDetail.vue b/ui-src/src/views/DatasetDetail.vue new file mode 100644 index 0000000..68a62b3 --- /dev/null +++ b/ui-src/src/views/DatasetDetail.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/ui-src/src/views/DatasetList.vue b/ui-src/src/views/DatasetList.vue new file mode 100644 index 0000000..14e76ff --- /dev/null +++ b/ui-src/src/views/DatasetList.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/ui-src/src/views/KgGraph.vue b/ui-src/src/views/KgGraph.vue new file mode 100644 index 0000000..fd2ad6a --- /dev/null +++ b/ui-src/src/views/KgGraph.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/ui-src/src/views/Layout.vue b/ui-src/src/views/Layout.vue new file mode 100644 index 0000000..6cbe068 --- /dev/null +++ b/ui-src/src/views/Layout.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/ui-src/src/views/Login.vue b/ui-src/src/views/Login.vue new file mode 100644 index 0000000..e4431c6 --- /dev/null +++ b/ui-src/src/views/Login.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/ui-src/src/views/Settings.vue b/ui-src/src/views/Settings.vue new file mode 100644 index 0000000..44939d8 --- /dev/null +++ b/ui-src/src/views/Settings.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/ui-src/vite.config.js b/ui-src/vite.config.js new file mode 100644 index 0000000..f7aaa5d --- /dev/null +++ b/ui-src/vite.config.js @@ -0,0 +1,32 @@ +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const target = env.VITE_API_PROXY_TARGET || 'http://localhost:8080' + + return { + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + port: 5173, + proxy: { + '/system-config': { target, changeOrigin: true }, + '/model-config': { target, changeOrigin: true }, + '/dataset': { target, changeOrigin: true }, + '/document': { target, changeOrigin: true }, + '/parse-task': { target, changeOrigin: true }, + '/conversation': { target, changeOrigin: true }, + '/message': { target, changeOrigin: true }, + '/kg-entity': { target, changeOrigin: true }, + '/kg-relation': { target, changeOrigin: true }, + '/workspace': { target, changeOrigin: true } + } + } + } +})