diff --git a/.codex/design/assets/create-dataset-modal-ui.png b/.codex/design/assets/create-dataset-modal-ui.png new file mode 100644 index 000000000000..e19bed94f9f5 Binary files /dev/null and b/.codex/design/assets/create-dataset-modal-ui.png differ diff --git a/.codex/design/assets/file-chunk-modal-ui.png b/.codex/design/assets/file-chunk-modal-ui.png new file mode 100644 index 000000000000..9c307f5ba106 Binary files /dev/null and b/.codex/design/assets/file-chunk-modal-ui.png differ diff --git a/.codex/design/assets/file-chunk-qa-modal-ui.png b/.codex/design/assets/file-chunk-qa-modal-ui.png new file mode 100644 index 000000000000..7c48ed3ebeef Binary files /dev/null and b/.codex/design/assets/file-chunk-qa-modal-ui.png differ diff --git a/.codex/design/assets/image-chunk-modal-ui.png b/.codex/design/assets/image-chunk-modal-ui.png new file mode 100644 index 000000000000..64a8568fc495 Binary files /dev/null and b/.codex/design/assets/image-chunk-modal-ui.png differ diff --git a/.codex/design/assets/model-config-reference-ui.jpeg b/.codex/design/assets/model-config-reference-ui.jpeg new file mode 100644 index 000000000000..afc60d6dc4e3 Binary files /dev/null and b/.codex/design/assets/model-config-reference-ui.jpeg differ diff --git a/.codex/design/assets/model-selector-dropdown-ui.png b/.codex/design/assets/model-selector-dropdown-ui.png new file mode 100644 index 000000000000..abd247781a80 Binary files /dev/null and b/.codex/design/assets/model-selector-dropdown-ui.png differ diff --git a/.codex/design/assets/search-test-base-ui.png b/.codex/design/assets/search-test-base-ui.png new file mode 100644 index 000000000000..54a12b594b80 Binary files /dev/null and b/.codex/design/assets/search-test-base-ui.png differ diff --git a/.codex/design/assets/search-test-upload-ui.png b/.codex/design/assets/search-test-upload-ui.png new file mode 100644 index 000000000000..a3c169ae128d Binary files /dev/null and b/.codex/design/assets/search-test-upload-ui.png differ diff --git a/.codex/design/assets/workflow-dataset-search-node-ui.png b/.codex/design/assets/workflow-dataset-search-node-ui.png new file mode 100644 index 000000000000..e8d0378c91f8 Binary files /dev/null and b/.codex/design/assets/workflow-dataset-search-node-ui.png differ diff --git "a/.codex/design/\345\233\276\346\220\234\345\233\276-\345\275\223\345\211\215\351\234\200\346\261\202-\345\212\237\350\203\275\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/.codex/design/\345\233\276\346\220\234\345\233\276-\345\275\223\345\211\215\351\234\200\346\261\202-\345\212\237\350\203\275\345\274\200\345\217\221\346\226\207\346\241\243.md" new file mode 100644 index 000000000000..0aa22185c0d9 --- /dev/null +++ "b/.codex/design/\345\233\276\346\220\234\345\233\276-\345\275\223\345\211\215\351\234\200\346\261\202-\345\212\237\350\203\275\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -0,0 +1,1043 @@ +# 功能开发文档 + +## 文档标识 + +- 任务前缀:`图搜图-当前需求` +- 文档文件名:`图搜图-当前需求-功能开发文档.md` +- 更新时间:2026-05-01 +- 文档状态:`v2.0 反向核对完成,补齐重建链路与搜索测试页 UI` +- 文档定位:面向开发和 AI 实现的任务拆解文档。旧的 `图搜图-接入-*` 文档不覆盖,本文件按用户最新确认需求重新规划。 + +## 0. 开发目标与约束 + +- 功能目标:接入图搜图能力,覆盖 embedding 模型配置页 `支持图片识别` 开关、创建知识库模型选择、图片自动索引、图片向量索引生成、分块弹窗 UI、搜索测试本地上传图片、工作流知识库搜索节点 `Array` 检索内容和后端图文混合检索。 +- 代码范围:`packages/global`、`packages/service`、`packages/web`、`projects/app`、`pro/admin`、`document/content`。 +- 非目标:不新增知识库类型;不新增搜索模式大类;不重做模型配置页整体框架;不做存量图片向量自动迁移;不做全量工作流文件系统重构;不改训练状态展示大类。 +- 实现原则:最小改动、复用现有搜索/RRF/权限/计费能力;图片向量索引和 VLM 文本索引分清楚;能靠配置和 helper 解决的不要到处散落判断。 +- 必须遵循规范:`references/style-standards-entry.md`。 +- 适用维度:API[x] DB[x] Front[x] Logger[x] Package[x] BugFix[ ] DocUpdate[x] DocI18n[x]。 + +## 1. 实施任务拆解(可直接执行) + +| 任务ID | 任务名称 | 责任层 | 输入 | 输出 | 完成定义(DoD) | +|---|---|---|---|---|---| +| T1 | 扩展 embedding 模型能力字段 | Global/Service | 模型配置 | `vision?: boolean` | 老配置可解析,helper 能判断 image 能力 | +| T2 | 改模型配置页 embedding 功能配置 | Front/API | embedding 模型设置表单 | `支持图片识别` 开关,默认关闭,打开后保存 `vision=true` | 样式参考 LLM 功能配置,关闭时 text-only | +| T3 | 改模型选择器标签 | Front | embedding model list | `Beta`、`多模态` 标签和 `多模态` hover 说明 | `Beta` 在前,长模型名不挤压标签;hover `多模态` 展示固定文案 | +| T4 | 改创建知识库弹窗 | Front/API | 新 UI 图、最新提示文案 | 宽弹窗、三模型字段、索引模型标签、QuestionTip | 创建成功参数保持兼容;索引模型和图片理解模型问号提示使用固定文案 | +| T5 | 改图片自动索引可用性 | Front/Global | 商业版、多模态、VLM 配置 | 动态 disabled、tooltip、tips | 5 种场景符合矩阵,禁用时提交值为 false | +| T6 | 增加图片 embedding 服务 | Service | 图片 URL/S3 key | 图片向量 | 支持 db/query 两种场景,错误可观测 | +| T7 | 增加图片向量索引类型和写入链路 | Global/Service/DB | 图片数据/图文数据 | `imageEmbedding` 索引向量 | 不污染现有 VLM `image` 文本索引 | +| T8 | 改训练和重建分流 | Service/Pro | 多模态/VLM 配置、当前 `vectorModel`、data.indexes | 文本索引和图片索引按当前模型能力分流训练/重建 | 重建不把图片引用当文本 embedding,模型切换后全库按新模型组合重建 | +| T9 | 改搜索核心 | Service | `textQueries + queryImageUrls`、当前知识库 `vectorModel/vlmModel` | 按模型能力分支召回 + RRF | 多模态 embedding 直接图/文检索;有 VLM 时合并图片转文字召回;普通 embedding 有 VLM 时图片先转文字;普通 embedding 无 VLM 时不做图片检索 | +| T10 | 改搜索测试 API 和本地上传图片 | API/Front | 本地图片、搜索参数 | 可测试图片输入 | `text` 可选;支持仅图片搜索;最多 10 张;只支持图片,不支持文件;格式和大小跟随系统并过滤不合法输入;上传对象 3 小时过期 | +| T11 | 改搜索测试页 UI/store | Front | 最新搜索测试上传图片与历史 hover 图 | 搜索配置、测试按钮、历史无 icon、输入框内上传按钮、缩略图、上传中卡片、历史图片 hover 缩略图浮层 | 视觉与交互符合新稿;超过 10 张提示 `最多支持上传10张图片`;无图片搜索能力时禁用上传按钮并提示 | +| T12 | 改工作流知识库搜索节点 | Global/Service/Front | `Array` 检索内容 | 同槽位接用户问题和文件链接,兼容层统一归一化后端仅保留图片链接 | 新节点为 arrayString,旧节点 string 由兼容层处理;PDF/docx 等非图片文件链接被过滤 | +| T13 | 改文件/图片分块弹窗 | Front/API | 第三、第四张图与最新补充图 | 新索引卡片、图片预览、图片内容、索引删除入口 | 多模态图片索引内容不可见;默认索引不可删;除默认索引外其他索引可删 | +| T14 | 补计费、日志、i18n、文档 | Service/Web/Docs | 新能力 | 用量统计、脱敏日志、中英文文档 | 文档和翻译同步 | +| T15 | 测试与验收 | Test | T1-T14 | 自动化/手工验证 | 局部测试通过,最终 `pnpm lint`/必要测试通过 | + +### 1.1 技术实现流程图(必填) + +```mermaid +flowchart TD + A["T1 模型能力字段
vision"] --> B["T2 模型配置页
支持图片识别开关"] + B --> C["T3/T4 创建知识库与模型下拉"] + B --> D["T5 图片自动索引矩阵"] + B --> E["T6 图片 embedding 服务"] + E --> F["T7 图片向量索引类型与写入"] + F --> G["T8 训练/重建分流"] + E --> H["T9 搜索核心
文本 + 图片召回"] + H --> I["T10 搜索测试 API + 本地上传"] + I --> J["T11 搜索测试页 UI/store"] + H --> K["T12 工作流检索内容 Array"] + G --> L["T13 分块弹窗索引卡片"] + J --> M["T14 文档/i18n/日志/计费"] + K --> M + L --> M + M --> N["T15 测试与验收"] +``` + +实现说明: + +- `T1` 是所有判断的地基,别在前后端散写 `model.includes('xxx')` 这种土法炼钢。 +- `T2` 是模型是否支持图片输入的唯一人工配置入口;embedding 模型复用现有 `vision` 字段,语义为“支持图片向量化”,不再新增 `modalities`。 +- `T5` 是本次用户特别补充点,禁用状态和提示文案必须跟矩阵一致。 +- `T12` 不能改成额外 `fileUrlList` 外露输入,用户已经确认“检索内容本身改 Array”;但数组里的文件链接只有图片能参与检索,非图片文件链接必须在后端过滤。 + +## 2. 文件级改动清单 + +| 文件路径 | 改动类型 | 变更摘要 | 关键代码(可伪代码) | 关联任务ID | +|---|---|---|---|---| +| `packages/global/core/ai/model.schema.ts` | 修改 | `EmbeddingModelItemSchema` 增加/复用 `vision` | `vision: z.boolean().optional()` | T1 | +| `packages/service/core/ai/model.ts` | 修改 | 增加能力判断 helper | `isImageEmbeddingModel(model)` | T1 | +| `projects/app/src/pageComponents/account/model/AddModelBox.tsx` | 修改 | embedding 模型设置表单新增 `功能配置` 区域和 `支持图片识别` 开关 | 开关默认关;打开保存 `vision=true`;关闭保存/恢复为 `vision=false` 或缺省 | T2 | +| `projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` | 修改 | 模型配置列表/设置入口识别 embedding `vision` | `vision=true` 显示 `多模态` 标签,`Beta` 在前 | T2/T3 | +| `projects/app/src/pages/api/core/ai/model/update.ts` | 修改 | 模型配置保存支持 embedding `vision` | schema parse 后保存 `vision`;老 embedding 无 `vision` 按 false | T2 | +| `projects/app/src/pages/api/core/ai/model/list.ts` | 修改/核查 | 模型配置列表返回 embedding `vision` | 前端下拉和配置页能拿到 image 能力 | T2/T3 | +| `packages/web/i18n/*/account_model.json` | 修改 | embedding 功能配置文案、图片理解模型 tip | 复用/新增 `支持图片识别` tip;更新/新增 `vlm_model_tip` 为 `自动标注文档里的图片并生成文本描述,辅助文本检索` | T2/T4 | +| `packages/web/i18n/*/common.json` | 修改 | 创建知识库索引模型 tip、多模态标签 hover 文案 | 更新 `core.dataset.embedding model tip` 或新增 `core.dataset.embedding_model_tip`;新增 `core.ai.model.multimodal_tip` | T3/T4/T14 | +| `projects/app/src/components/Select/AIModelSelector.tsx` | 修改 | 支持 `Beta`、`多模态` 标签顺序和 hover | tag list 先 beta 后 multimodal;`多模态` tag hover 展示 `多模态索引模型可以给图片生成向量。` | T3 | +| `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 修改 | 弹窗和字段布局按第一张图,并接入新 QuestionTip 文案 | 宽度、label、selector 样式调整;`索引模型` 和 `图片理解模型` 问号提示用固定 i18n 文案 | T4 | +| `projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx` | 修改 | 图片自动索引 disabled/tips 矩阵 | `getImageIndexConfigState()` | T5 | +| `packages/web/i18n/*/dataset.json` | 修改 | 图片自动索引动态文案、多模态图片索引默认说明、索引删除确认文案 | 新增 tips/default description/delete confirm key | T5/T13/T14 | +| `packages/service/core/ai/embedding/index.ts` | 修改 | 增加 `getVectorsByImage` | 图片输入转 embedding request | T6 | +| `packages/service/common/vectorDB/controller.ts` | 修改 | 支持外部预计算向量写入 | `insertDatasetVectors()` | T6/T7 | +| `packages/global/core/dataset/data/constants.ts` | 修改 | 新增 `DatasetDataIndexTypeEnum.imageEmbedding` | `imageEmbedding = 'imageEmbedding'` | T7 | +| `packages/global/core/dataset/constants.ts` | 修改 | 新增 `SearchScoreTypeEnum.imageEmbedding` | 用于 quote 分数展示 | T7/T9 | +| `projects/app/src/service/core/dataset/data/controller.ts` | 修改 | 数据写入按索引类型分流,并支持非默认索引删除时同步清理后端数据/向量 | imageEmbedding 走图片向量;删除索引不能只做 UI 过滤 | T7/T13 | +| `projects/app/src/pages/api/core/dataset/collection/create/images.ts` | 修改 | 多模态索引模型时不再强制要求 VLM | `if (!vlm && !isImageEmbeddingModel) error` | T7/T8 | +| `projects/app/src/pages/api/core/dataset/data/insertImages.ts` | 修改 | 图片追加后按能力生成向量/文本索引 | 多模态无 VLM 仍可入队 | T7/T8 | +| `pro/admin/src/service/core/dataset/training/imageParse.ts` | 核查/复用 | 有 VLM 时继续生成文本描述索引;图片向量由后续 chunk/insert/rebuild 链路按多模态模型补齐 | 按能力分流 | T8 | +| `pro/admin/src/service/core/dataset/training/imageIndex.ts` | 修改 | 图文文档图片索引时仅生成 VLM 文本索引,保留原始 `q` 中的 markdown 图片引用 | 不复用 `image` 做图片向量;图片向量索引统一在 `generateVector` 建索引阶段补齐 | T8 | +| `projects/app/src/service/core/dataset/queues/generateVector.ts` | 修改 | 初次导入和重建时统一补齐 markdown 图片的 `imageEmbedding`,并按 `index.type` 分流 | 文本索引走文本 embedding;图片索引走 `getVectorsByImage`;不按文件格式分支 | T8 | +| `projects/app/src/pages/api/core/dataset/update.ts` | 修改 | 更新 `vectorModel` 或 `vlmModel` 后标记/触发全库重建 | 重建按切换后的模型组合重新生成索引 | T5/T8 | +| `packages/service/core/dataset/search/controller.ts` | 修改 | 增加 `queryImageUrls`/图片向量召回 | `runImageRecall()` | T9 | +| `packages/global/openapi/core/dataset/api.ts` | 修改 | `SearchDatasetTestBodySchema` 支持 `text?`、`queryImageUrls?` | refine 至少一个输入;`queryImageUrls.max(10)`;允许纯图片搜索 | T10 | +| `projects/app/src/pages/api/core/dataset/searchTest.ts` | 修改 | 搜索测试接收文本和图片 | 上传后的图片 URL 参与搜索;拒绝超过 10 张图片;空文本 + 有图片可搜索 | T10 | +| 搜索测试图片上传接口/服务 | 新增/复用 | 上传本地图片为搜索测试临时输入 | 只接收图片;写入 S3 TTL,`expiredTime = addHours(new Date(), 3)`;不要复用正式图片集导入的 7 天过期策略 | T10 | +| `projects/app/src/web/core/dataset/api.ts` | 修改 | 更新搜索测试类型和图片上传 API wrapper | `postSearchText` 可保留名或重命名;图片上传只传图片文件 | T10 | +| `projects/app/src/pageComponents/dataset/detail/Test.tsx` | 修改 | 搜索测试页 UI 改版、本地上传图片和历史图片 hover 预览 | 搜索配置按钮、输入框左下角图片上传按钮、顶部缩略图/删除/上传中卡片、历史图片 hover 缩略图浮层、测试按钮下移;最多 10 张和过滤规则;普通 embedding 且无 VLM 时禁用图片按钮 | T11 | +| `projects/app/src/web/core/dataset/store/searchTest.ts` | 修改 | 历史支持图片摘要、缩略图引用并兼容旧数据 | `imageCount/queryImageRefs/queryImagePreviewRefs` | T11 | +| `packages/global/core/workflow/template/system/datasetSearch.ts` | 修改 | 检索内容 valueType 改 `arrayString` | 保持 key 为 `userChatInput`,改 valueType | T12 | +| `packages/service/core/workflow/dispatch/dataset/search.ts` | 修改 | 增加兼容层读取旧 string 或新 arrayString,并归一化文本和图片,过滤非图片文件链接 | `normalizeDatasetSearchInput()` 返回 `textQueries/queryImageUrls/filteredFileCount`;业务搜索层不扩散 `string | string[]` | T12 | +| `projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/components/NodeTemplates/list.tsx` | 修改/核查 | 新建 dataset search 节点默认只连接用户问题,文件链接由用户按需手动添加 | arrayString 默认单引用,支持后续追加文件链接引用 | T12 | +| `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 修改 | 文件/图片分块弹窗新样式、索引内容可见性和删除入口 | `DataIndexPanel`、`ImageContentPanel`、`IndexDeleteAction` | T13 | +| `projects/app/src/pages/api/core/dataset/data/update.ts` | 修改/核查 | 删除非默认索引时同步更新 `indexes[]`,必要时清理对应向量记录 | 默认索引拒绝删除,多模态图片索引允许删除 | T13 | +| `projects/app/src/components/core/dataset/QuoteItem.tsx` | 修改 | 展示图片向量分数类型 | `SearchScoreTypeMap.imageEmbedding` | T14 | +| `document/content/openapi/dataset.mdx` | 修改 | 更新搜索测试 API | 图片上传/纯图片搜索/最多 10 张/图文混合示例 | T14 | +| `document/content/openapi/dataset.en.mdx` | 修改 | 英文同步 | 字段名保持不翻译 | T14 | + +## 2.1 关键代码片段(用于规划核对) + +### 2.1.1 模型能力字段与 helper + +```ts +// packages/global/core/ai/model.schema.ts +export const EmbeddingModelItemSchema = BaseAIModelSchema.extend({ + vision: z.boolean().optional() +}); + +// packages/service/core/ai/model.ts +export const isImageEmbeddingModel = (model?: string) => { + const modelData = getEmbeddingModel(model); + return !!modelData.vision; +}; +``` + +### 2.1.2 embedding 模型配置开关映射 + +```ts +// projects/app/src/pageComponents/account/model/AddModelBox.tsx +// 注意:embedding 复用 vision 字段,但语义是图片向量化能力。 +const supportImageRecognition = !!watch('vision'); + + { + setValue('vision', e.target.checked); + }} +/>; +``` + +实现要求: + +1. 开关默认关闭,旧 embedding 模型无 `vision` 时按 text-only 展示。 +2. 打开后保存 `vision=true`;关闭后保存/恢复为 `vision=false` 或缺省。 +3. UI 样式参考 LLM 模型 `功能配置` 区域,但 helper 必须区分 LLM 的图片理解能力和 embedding 的图片向量化能力,别在业务里裸读字段到处判断。 +4. 配置保存成功后,模型配置列表、创建知识库索引模型下拉、图片自动索引矩阵都必须读到同一份 `vision`。 + +### 2.1.3 图片自动索引状态矩阵 + +```ts +// projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx +const getImageIndexConfigState = ({ + isPlus, + isImageEmbeddingModel, + vlmModel +}: { + isPlus: boolean; + isImageEmbeddingModel: boolean; + vlmModel?: string; +}) => { + if (!isPlus) { + return { + disabled: true, + tooltip: t('common:commercial_function_tip'), + tip: t('dataset:image_auto_parse_tip_commercial') + }; + } + + if (isImageEmbeddingModel && vlmModel) { + return { + disabled: false, + tooltip: '', + tip: t('dataset:image_auto_parse_tip_multimodal_with_vlm') + }; + } + + if (isImageEmbeddingModel) { + return { + disabled: false, + tooltip: '', + tip: t('dataset:image_auto_parse_tip_multimodal_without_vlm') + }; + } + + if (vlmModel) { + return { + disabled: false, + tooltip: '', + tip: t('dataset:image_auto_parse_tip_vlm_only') + }; + } + + return { + disabled: true, + tooltip: t('dataset:image_auto_parse_tip_no_vlm_or_multimodal'), + tip: t('dataset:image_auto_parse_tip_no_vlm_or_multimodal') + }; +}; +``` + +### 2.1.4 搜索测试 schema + +```ts +// packages/global/openapi/core/dataset/api.ts +export const SearchDatasetTestBodySchema = z + .object({ + datasetId: ObjectIdSchema, + text: z.string().optional(), + queryImageUrls: z.array(z.string()).max(10, '最多支持上传10张图片').optional(), + limit: z.number().optional(), + similarity: z.number().optional(), + searchMode: z.enum(DatasetSearchModeEnum).optional(), + usingReRank: z.boolean().optional(), + datasetSearchUsingExtensionQuery: z.boolean().optional() + }) + .refine((data) => !!data.text?.trim() || !!data.queryImageUrls?.length, { + message: 'text or queryImageUrls is required' + }); +``` + +实现要求: + +1. `text` 可为空,只要 `queryImageUrls` 有值就允许搜索,支持“仅上传图片,不带文字”。 +2. schema 只校验输入形态,不判断知识库是否支持图片搜索;普通 embedding 且无 VLM 的能力限制由前端禁用上传按钮和搜索核心兜底处理。 +3. 图片数量上限为 10 张;前端和后端都要校验,前端超出时提示 `最多支持上传10张图片`,后端作为兜底防绕过。 +4. 搜索测试上传入口只支持图片,不支持 PDF、docx、xlsx、txt、pptx 等文件。 +5. 图片格式和大小限制跟随系统现有上传规则,不在搜索测试里单独定义一套阈值。 +6. 非图片文件、系统不支持格式、超出系统大小限制的图片直接过滤,不加入待上传列表,也不生成 `queryImageUrls`。 +7. 如果一批选择中同时有合法图片和非法文件,合法图片正常加入,非法项过滤;只有图片数量超过 10 张需要使用本需求指定提示文案。 +8. 搜索测试上传图片只用于临时检索,上传对象必须设置 3 小时过期时间:`expiredTime = addHours(new Date(), 3)`,并走现有 S3 TTL 清理链路。 +9. 搜索测试历史/store 不保存 base64、完整私有 URL 或长期可访问 URL,只保存图片数量、文本摘要和受控缩略图引用。 + +### 2.1.5 工作流检索内容归一化 + +```ts +// packages/service/core/workflow/dispatch/dataset/search.ts +import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { parseUrlToFileType } from '@fastgpt/service/core/workflow/utils/context'; + +const isLikelyFileLinkValue = (value: string) => { + const trimmed = value.trim(); + return ( + trimmed.startsWith('data:') || + trimmed.startsWith('chat/') || + trimmed.startsWith('/') || + /^https?:\/\//i.test(trimmed) + ); +}; + +const normalizeDatasetSearchInput = (rawInput?: unknown) => { + // 兼容层:旧节点可能存 string,新节点为 arrayString;业务层只使用归一化结果。 + const input = + typeof rawInput === 'string' || Array.isArray(rawInput) ? rawInput : undefined; + const values = Array.isArray(input) ? input : input ? [input] : []; + + return values.reduce( + (acc, value) => { + const trimmed = value.trim(); + if (!trimmed) return acc; + + if (isLikelyFileLinkValue(trimmed)) { + const file = parseUrlToFileType(trimmed); + if (file?.type === ChatFileTypeEnum.image) { + acc.queryImageUrls.push(trimmed); + return acc; + } + + if (file?.type === ChatFileTypeEnum.file) { + acc.filteredFileCount += 1; + return acc; + } + } + + acc.textQueries.push(trimmed); + return acc; + }, + { + textQueries: [] as string[], + queryImageUrls: [] as string[], + filteredFileCount: 0 + } + ); +}; +``` + +过滤口径: + +1. 只把 `parseUrlToFileType(value)?.type === ChatFileTypeEnum.image` 的链接放入 `queryImageUrls`。 +2. `ChatFileTypeEnum.file` 的链接,包括 PDF、docx、xlsx、txt、pptx、html 等,一律过滤,不进入 `textQueries`。 +3. 普通用户问题仍进入 `textQueries`;不要对所有字符串无脑调用 `parseUrlToFileType` 后就当文件处理,因为当前 parser 对无后缀文本也可能返回 file,容易误伤正常问题。 +4. 不建议在工作流 dispatch 阶段对 URL 发起 HEAD 请求探测 MIME,成本、权限和 SSRF 风险都不划算;优先使用 `queryUrlTypeMap`、上传时文件类型和后缀白名单判断。 +5. 过滤不作为节点错误。若输入数组只有非图片文件链接且无文本,节点按空检索返回空结果,并在 `nodeResponse` 或日志里记录 `filteredFileCount`,不记录完整 URL。 + +### 2.1.6 搜索核心分流 + +```ts +// packages/service/core/dataset/search/controller.ts +const supportImageEmbedding = isImageEmbeddingModel(dataset.vectorModel); +const hasVlm = !!dataset.vlmModel; + +const imageCaptionQueries = + queryImageUrls.length && hasVlm + ? await getQueryImageCaptionsByVlm({ + imageUrls: queryImageUrls, + vlmModel: dataset.vlmModel + }) + : []; + +const recallTasks = [ + textQueries.length + ? runTextRecall({ + textQueries, + searchMode, + usingReRank, + datasetSearchUsingExtensionQuery + }) + : Promise.resolve([]), + + imageCaptionQueries.length + ? runTextRecall({ + textQueries: imageCaptionQueries, + searchMode, + usingReRank: false, + datasetSearchUsingExtensionQuery: false, + source: 'imageCaption' + }) + : Promise.resolve([]), + + queryImageUrls.length && supportImageEmbedding + ? runImageRecall({ + imageUrls: queryImageUrls, + model: dataset.vectorModel, + datasetIds, + limit + }) + : Promise.resolve([]) +]; + +const [textRecallResult, imageCaptionRecallResult, imageRecallResult] = + await Promise.all(recallTasks); + +const mergedResult = datasetSearchResultConcat( + [ + { weight: textQueries.length ? embeddingWeight : 0, list: textRecallResult }, + { weight: imageCaptionQueries.length ? embeddingWeight : 0, list: imageCaptionRecallResult }, + { weight: queryImageUrls.length && supportImageEmbedding ? 1 : 0, list: imageRecallResult } + ].filter((item) => item.weight > 0) +); +``` + +分支规则: + +1. 多模态 embedding 的知识库: + - 纯文本:文本 query 使用同一个多模态 embedding 的 text modality 检索。 + - 纯图片:图片 query 使用 image modality 检索 `imageEmbedding`。 + - 图片 + 文字:文本分支和图片向量分支并行召回后 RRF 合并。 +2. 多模态 embedding + VLM: + - 除图片向量召回外,查询图片还可以先经 VLM 转成文本,再检索 VLM 文本描述索引。 + - 同一数据同时命中 `imageEmbedding` 和 VLM 文本描述索引时,RRF 合并后排序权重自然提升。 +3. 普通 embedding + VLM: + - 图片不能直接走图片 embedding。 + - 查询图片先经 VLM 转文字,再用普通 embedding 做文本检索。 + - 图片 + 文字时,用户文字和图片 caption 作为多路文本 query 合并。 +4. 普通 embedding + 无 VLM: + - 不做图片检索。 + - 纯图片返回空召回结果;图片 + 文字只使用文字部分,不额外报错。 +5. Query Extension 和 ReRank 默认只作用在用户文本分支;图片 caption 分支是否开启扩展/重排应保守处理,避免 VLM 已生成的描述被二次扩写导致语义漂移。 + +### 2.1.7 模型切换后的训练方式选择 + +```ts +// 伪代码:dataset vectorModel/vlmModel 更新后统一调用 +const resolveImageIndexStrategy = ({ + vectorModel, + vlmModel, + imageIndex +}: { + vectorModel: string; + vlmModel?: string; + imageIndex: boolean; +}) => { + const supportImageEmbedding = isImageEmbeddingModel(vectorModel); + + return { + enableImageEmbeddingIndex: imageIndex && supportImageEmbedding, + enableVlmTextIndex: imageIndex && !!vlmModel, + normalizedImageIndex: imageIndex && (supportImageEmbedding || !!vlmModel) + }; +}; + +const handleDatasetModelChanged = async (datasetId: string) => { + // 模型切换后全部重建,不能只局部清理旧 imageIndex。 + await markDatasetRebuildRequired(datasetId); +}; +``` + +### 2.1.8 重建索引分流 + +```ts +// projects/app/src/service/core/dataset/queues/generateVector.ts +const textIndexes = indexes.filter((item) => item.type !== DatasetDataIndexTypeEnum.imageEmbedding); +const imageIndexes = indexes.filter((item) => item.type === DatasetDataIndexTypeEnum.imageEmbedding); + +await Promise.all([ + textIndexes.length + ? rebuildTextVectors({ dataId, indexes: textIndexes, model }) + : Promise.resolve(), + imageIndexes.length + ? rebuildImageVectors({ dataId, indexes: imageIndexes, model }) + : Promise.resolve() +]); +``` + +### 2.1.9 文档图文分块的多模态图片索引补齐 + +目标:把 DOCX、PDF 解析服务、HTML、Markdown、网页等来源统一成同一种处理方式。只要最终训练分块里保留 markdown 图片引用,并且当前知识库启用了图片自动索引、索引模型支持图片向量化,就在建索引阶段补齐 `imageEmbedding`。不要按文件扩展名分别写补丁。 + +职责边界: + +1. `pro/admin/src/service/core/dataset/training/imageIndex.ts` 只负责 VLM 识别图片并生成 `DatasetDataIndexTypeEnum.image` 文本索引。 +2. `projects/app/src/service/core/dataset/queues/generateVector.ts` 负责在真正建索引前追加 `DatasetDataIndexTypeEnum.imageEmbedding`。 +3. `insertData2Dataset` / `updateData2Dataset` 继续负责按索引类型分流:文本索引走文本 embedding,`imageEmbedding` 走 `getVectorsByImage`。 + +图片来源收敛: + +| 来源 | 是否作为补齐来源 | 原因 | +|---|---:|---| +| `trainingData.q` | 是 | 初次导入和 VLM/auto 处理后的训练记录仍应保留原始分块文本,是文档图文分块的主来源 | +| `trainingData.data.q` | 是,仅重建兜底 | 重建时可从已入库原始数据恢复 markdown 图片,避免 VLM 后续改写 `q` 时漏图 | +| 已有 `indexes` 中的 `imageEmbedding` | 否,仅用于去重 | 不能当来源重复生成,只用于避免重复追加同一图片 | +| `trainingData.imageId` / `trainingData.data.imageId` | 否,本方案不处理 | 图片数据集/单图数据已有 `insertData2Dataset` 的 `imageId` 链路,和文档 markdown 图片补齐分开 | +| `imageDescMap` 的 key | 否 | 它是 VLM 描述映射结果,不作为图片来源;避免依赖 VLM 产物来驱动图片向量索引 | + +推荐伪代码: + +```ts +const getMarkdownImageUrlsFromTrainingData = (trainingData: TrainingDataType) => { + const texts = [trainingData.q, trainingData.data?.q].filter(Boolean) as string[]; + return unique(texts.flatMap(matchMarkdownImageUrls)); +}; + +const appendMarkdownImageEmbeddingIndexes = ({ + indexes, + trainingData, + embModel +}: { + indexes: DatasetDataIndexItemType[]; + trainingData: TrainingDataType; + embModel: ReturnType; +}) => { + if (!trainingData.collection.imageIndex) return indexes; + if (!isImageEmbeddingModel(embModel)) return indexes; + + const existedImageUrls = new Set( + indexes + .filter((item) => item.type === DatasetDataIndexTypeEnum.imageEmbedding) + .map((item) => item.text) + ); + + const appendIndexes = getMarkdownImageUrlsFromTrainingData(trainingData) + .filter((url) => !existedImageUrls.has(url)) + .map((url) => ({ + type: DatasetDataIndexTypeEnum.imageEmbedding, + text: url, + dataId: '' + })); + + return indexes.concat(appendIndexes); +}; +``` + +接入要求: + +1. `rebuildData()` 调用该 helper,保证存量数据重建时能从 `trainingData.data.q` 补齐图片向量索引。 +2. `insertData()` 调用该 helper,保证 DOCX/PDF/HTML/Markdown/网页等初次导入时就生成 `imageEmbedding`。 +3. 不在 `imageIndex.ts` 中调用 `getVectorsByImage`,避免 VLM 队列承担建向量职责。 +4. 普通 embedding 模型或 `collection.imageIndex=false` 时不追加 `imageEmbedding`,避免出现没有实际向量的假索引卡片。 +5. 同一分块里相同图片 URL 只追加一次 `imageEmbedding`。 + +## 3. 后端实施说明 + +### 3.1 API 改动 + +| 路由 | 方法 | 请求参数 | 响应结构 | 鉴权 | 错误处理 | +|---|---|---|---|---|---| +| `/api/core/ai/model/update` | PUT | embedding 模型配置中的 `vision?` | 更新后的模型配置或成功状态 | `authSystemAdmin` | 非法字段类型、模型配置解析失败 | +| `/api/core/ai/model/list` | GET | 原查询参数 | embedding 模型返回 `vision` | `authSystemAdmin` | 模型配置解析失败 | +| `/api/core/dataset/searchTest` | POST | `datasetId`、`text?`、`queryImageUrls?`、原搜索配置 | 复用 `SearchDatasetTestResponse`,可扩展参数摘要 | `authDataset` Read + AI points check | 空输入、图片超过 10 张、图片读取失败 | +| 搜索测试图片上传接口 | POST | multipart 图片文件,不支持普通文件 | 图片 URL/S3 key/文件信息,上传对象 3 小时过期 | 登录团队权限 | 非图片文件直接过滤;图片格式/大小跟随系统限制,超出直接过滤;上传失败;未写入 TTL | + +请求示例: + +```json +{ + "datasetId": "68ad85a7463006c963799a05", + "text": "找一下类似图片", + "queryImageUrls": ["dataset/tmp/search-test/flower.png"], + "limit": 5000, + "similarity": 0.4, + "searchMode": "mixedRecall", + "usingReRank": false +} +``` + +响应示例: + +```json +{ + "list": [], + "duration": "0.523s", + "limit": 5000, + "searchMode": "mixedRecall", + "usingReRank": false, + "similarity": 0.4, + "queryExtensionModel": "" +} +``` + +### 3.2 Service/Core 改动 + +| 模块 | 函数/类型 | 具体改动 | 依赖关系 | +|---|---|---|---| +| AI 模型 | `EmbeddingModelItemSchema` | 增加/复用 `vision` | 前后端模型判断 | +| AI 模型 helper | `isImageEmbeddingModel` | 缺省 text-only | 图片自动索引、训练、搜索 | +| AI 模型配置 API | `update.ts`/`list.ts` | 保存和返回 embedding `vision` | 模型配置页、模型下拉 | +| Embedding | `getVectorsByImage` | 图片 URL/S3 key 生成向量 | 图片索引、图片 query | +| VectorDB | `insertDatasetVectors` | 支持预计算向量写入 | 避免图片被文本化 | +| Dataset data | `insertData2Dataset`/`updateData2Dataset` | 文本和图片索引分流 | 数据新增/编辑 | +| Dataset update | `projects/app/src/pages/api/core/dataset/update.ts` | 切换 `vectorModel` 或 `vlmModel` 时标记/触发全库重建,并按新模型组合重算图片自动索引策略 | 防止新模型配置继续沿用旧索引生成方式 | +| Training | `imageParse`/`imageIndex` | 只生成 VLM 文本描述索引,保留 markdown 图片引用给后续建索引阶段使用 | Pro 队列 | +| Vector build/Rebuild | `generateVector.ts` | 初次导入和重建统一从 `trainingData.q` / `trainingData.data.q` 补齐 markdown 图片的 `imageEmbedding`,再根据 `DatasetDataIndexTypeEnum` 分流建向量 | 避免索引错路;避免按文件格式补丁化 | +| Search | `searchDatasetData` | 图片召回与文本召回合并 | 搜索测试、工作流 | +| Workflow | `dispatch/dataset/search.ts` | 兼容层读取旧 string 或新 arrayString,图片链接入检索,非图片文件链接过滤 | 新旧工作流兼容,业务层只吃归一化结果 | + +### 3.3 数据层改动 + +| 集合/表 | 字段 | 类型 | 必填 | 默认值 | 索引 | 迁移策略 | +|---|---|---|---|---|---|---| +| 模型配置 | `vision` | boolean | 否 | helper 中视为 `false` | 无 | 不迁移旧配置;embedding 场景语义为图片向量化 | +| `dataset_datas.indexes.type` | `imageEmbedding` | enum | 否 | N/A | 复用现有 index | 仅新数据或重建后生成 | +| `dataset_datas.indexes.text` | 图片引用 | string | 是 | N/A | 复用现有 index | 存可控 URL/S3 key,不存 base64 | +| 向量库 | 无新增字段 | N/A | N/A | N/A | 复用 | 图片 embedding 维度与现有向量写入链路保持一致,维度不兼容时沿用现有校验/报错 | + +### 3.4 计费与用量 + +| 场景 | 现有能力 | 新增/调整 | +|---|---|---| +| 文本 embedding | 已有 | 保持 | +| 图片 embedding 索引 | 无明确图片 embedding 统计 | 记录模型、图片数量、返回 usage;若模型无 token usage,按图片数量计入可观测字段 | +| 搜索测试图片 query | `pushDatasetTestUsage` 只统计文本 embedding/rerank/extension | 增加图片 embedding usage 汇总 | +| 工作流图片 query | workflow nodeResponse 记录模型使用 | 增加图片 embedding 用量,避免账单对不上 | +| VLM 文本描述索引 | 已有 VLM 训练用量 | 多模态 + VLM 时仍记录 VLM 用量 | + +### 3.5 搜索策略 + +| 知识库配置 | 输入 | 文本 embedding/全文检索 | 图片 embedding | VLM 查询图片转文字 | RRF | +|---|---|---|---|---|---| +| 多模态 embedding,无 VLM | 纯文本 | 是,使用 text modality | 否 | 否 | 单路文本结果 | +| 多模态 embedding,无 VLM | 纯图 | 否 | 是,检索 `imageEmbedding` | 否 | 多图图片召回 RRF | +| 多模态 embedding,无 VLM | 图文混合 | 是,用户文本分支 | 是,图片向量分支 | 否 | 文本 + 图片 RRF | +| 多模态 embedding,有 VLM | 纯文本 | 是,可命中 VLM 文本描述索引 | 否 | 否 | 文本索引结果合并 | +| 多模态 embedding,有 VLM | 纯图 | 是,来自查询图片 caption | 是,检索 `imageEmbedding` | 是 | 图片向量 + VLM caption RRF,同数据多路命中权重提升 | +| 多模态 embedding,有 VLM | 图文混合 | 是,用户文本 + 查询图片 caption | 是 | 是 | 用户文本 + 图片向量 + VLM caption 多路 RRF | +| 普通 embedding,有 VLM | 纯文本 | 是 | 否 | 否 | 文本结果 | +| 普通 embedding,有 VLM | 纯图 | 是,来自查询图片 caption | 否 | 是 | caption 文本召回,多图 caption RRF | +| 普通 embedding,有 VLM | 图文混合 | 是,用户文本 + 查询图片 caption | 否 | 是 | 多路文本 RRF | +| 普通 embedding,无 VLM | 纯文本 | 和现在一样 | 否 | 否 | 和现在一样 | +| 普通 embedding,无 VLM | 纯图 | 否 | 否 | 否 | 空召回,不额外报错 | +| 普通 embedding,无 VLM | 图文混合 | 只使用文字部分 | 否 | 否 | 文本结果 | + +实现注意: + +1. 不能把所有 `queryImageUrls` 都直接丢给图片 embedding。只有 `isImageEmbeddingModel(dataset.vectorModel)` 为 true 时才允许。 +2. 有 VLM 时,查询图片也要转成文本,才能检索入库阶段生成的 VLM 文本描述索引。 +3. 同一数据同时被图片向量索引和 VLM 文本描述索引召回时,不要去重到只保留一条召回源;应进入 RRF 合并,让排序权重自然变大。 +4. 普通 embedding 无 VLM 的纯图片输入返回空列表即可,不作为接口错误;图文混合时忽略图片分支。 +5. 日志中可记录 `textQueryCount/imageQueryCount/imageCaptionQueryCount/supportImageEmbedding/hasVlm`,不要记录完整图片 URL 和完整 caption。 + +## 4. 前端实施说明 + +| 页面/组件 | 文件路径 | 交互变化 | i18n 改动 | 状态覆盖 | +|---|---|---|---|---| +| 模型配置表单 | `projects/app/src/pageComponents/account/model/AddModelBox.tsx` | embedding 模型设置里新增 `功能配置` 区域和 `支持图片识别` 开关,默认关闭 | `account:model.vision`、建议新增 `account:model.embedding_vision_tip` | 初始值、保存中、保存失败、打开/关闭 | +| 模型配置列表 | `projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` | 根据 embedding `vision=true` 展示多模态标签,`Beta` 在前 | `core.ai.model.multimodal`、`core.ai.model.multimodal_tip` | 长名称、标签拥挤、无 vision、多模态 hover | +| 创建知识库弹窗 | `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 宽弹窗;名称、索引模型、文本理解模型、图片理解模型按新稿;索引模型和图片理解模型问号提示使用固定文案 | `core.dataset.embedding_model_tip` 或旧 key 替换、`vlm_model_tip`、创建文案 | 加载/空/错误/创建中/QuestionTip hover | +| 模型下拉 | `projects/app/src/components/Select/AIModelSelector.tsx` | 模型名后展示 `Beta`、`多模态`,Beta 在前;hover 多模态标签展示能力说明 | `core.ai.model.multimodal`、`core.ai.model.multimodal_tip` | 长名称、标签拥挤、选中态、多模态 hover | +| 图片自动索引 | `projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx` | 按 5 场景矩阵动态 disabled 和 tips | 5 个 tips key | 商业版/多模态/VLM 组合 | +| 文件分块弹窗 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 左内容 textarea + 生成索引按钮;右索引卡片 | 多模态图片索引、生成索引 | loading/edit/save/error | +| 图片分块弹窗 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 左图片预览 + 图片内容 textarea;右索引卡片 | 图片内容、多模态图片索引 | 图片加载失败、编辑、保存 | +| 搜索测试页 | `projects/app/src/pageComponents/dataset/detail/Test.tsx` | `搜索配置` 按钮、输入框左下角图片上传按钮、测试按钮放框下、历史无 icon、图片历史 hover 预览、支持本地上传图片;无图片搜索能力时图片按钮 disabled | 上传图片、删除图片、输入测试内容、搜索配置、历史图片预览、图片数量超限提示、无图片能力提示 | 空/纯图片/图片上传中/图片过滤/图片按钮禁用/测试中/失败/结果/历史 hover | +| 搜索历史 store | `projects/app/src/web/core/dataset/store/searchTest.ts` | 保存文本摘要、图片数量、受控缩略图引用 | N/A | 兼容旧历史,不保存 base64/完整私有 URL | +| 工作流知识库搜索节点 | `packages/global/core/workflow/template/system/datasetSearch.ts` | “检索内容”改 `Array`,可多引用;运行时只接受图片文件链接参与检索 | `workflow:content_to_search` | 旧节点 string 值、新节点 array 值;非图片文件链接过滤 | + +### 4.1 UI 参考图全集 + +说明:下面这些图用于开发实现时对齐 UI,图片来源为 `/Users/xxyyh/Desktop/figme` 中导出的真实 Figma 图片。原先临时手绘的 SVG 已移除,后续实现以这些 PNG/JPEG 为准。 + +#### 4.1.1 创建通用知识库弹窗 + +![创建通用知识库弹窗](./assets/create-dataset-modal-ui.png) + +#### 4.1.2 索引模型下拉样式 + +![索引模型下拉样式](./assets/model-selector-dropdown-ui.png) + +实现注意:截图里展示顺序看起来是 `多模态` 在前,但用户已确认最终实现为 `Beta` 放在 `多模态` 前。hover `多模态` 标签时展示 `多模态索引模型可以给图片生成向量。` 开发时按文字口径实现,别被旧截图带偏。 + +#### 4.1.3 文件分块点击后弹窗 + +![文件分块点击后弹窗](./assets/file-chunk-modal-ui.png) + +#### 4.1.4 QA 模式文件分块弹窗 + +![QA 模式文件分块弹窗](./assets/file-chunk-qa-modal-ui.png) + +#### 4.1.5 图片分块点击后弹窗 + +![图片分块点击后弹窗](./assets/image-chunk-modal-ui.png) + +#### 4.1.6 工作流知识库搜索节点 + +![工作流知识库搜索节点](./assets/workflow-dataset-search-node-ui.png) + +#### 4.1.7 搜索测试页基础态 + +![搜索测试页基础态](./assets/search-test-base-ui.png) + +#### 4.1.8 搜索测试页上传图片与历史 hover 态 + +![搜索测试页上传图片与历史 hover 态](./assets/search-test-upload-ui.png) + +#### 4.1.9 模型配置页参考 + +![模型配置页参考](./assets/model-config-reference-ui.jpeg) + +实现注意:该图只表达 embedding 模型设置里新增 `支持图片识别` 开关的视觉样式。开发时参考 LLM 模型已有 `功能配置` 区域,保存字段为 embedding 模型的 `vision`。 + +### 4.2 创建知识库弹窗与模型下拉 UI 细节 + +1. `索引模型` label 后保留问号提示,hover/click 后展示:`索引模型可以将知识库内容转成向量,用于进行语义检索。注意,不同索引模型的知识库无法同时查询,切换索引模型需重建全量向量索引,请慎重选择。` +2. `图片理解模型` label 后保留问号提示,hover/click 后展示:`自动标注文档里的图片并生成文本描述,辅助文本检索` +3. 索引模型下拉中,支持图片的 embedding 模型展示 `Beta` 和 `多模态` 标签时,顺序必须是 `Beta` 在前、`多模态` 在后。 +4. hover `多模态` 标签时展示:`多模态索引模型可以给图片生成向量。` +5. `多模态` hover 只绑定在 `多模态` 标签上,不绑定整行模型;`Beta` 标签不展示该说明。 +6. 长模型名仍按现有省略规则处理,不能把 `Beta`、`多模态` 标签挤出可视区域。 +7. 以上三段文案必须走 i18n,覆盖 zh-CN/en/zh-Hant;中文文案以本文档为准。 +8. 现有 `common.json` 中旧 `索引模型可以将自然语言转成向量...选择完索引模型后将无法修改` 口径需要替换或停用,别让新旧文案在不同入口同时出现,用户看完容易怀疑人生。 + +### 4.3 模型配置页 UI 细节 + +1. 入口仍是现有模型配置页,针对 embedding 模型点击设置后进入 `AddModelBox` 表单。 +2. 在 embedding 模型表单中新增 `功能配置` 区域,位置和样式参考 LLM 模型已有的 `功能配置`。 +3. 区域内新增 `支持图片识别` 开关,默认关闭。 +4. 开关关闭时,保存/恢复为 `vision=false` 或缺省,模型按 text-only 处理。 +5. 开关打开时,保存 `vision=true`。 +6. 开关再次关闭时必须关闭 `vision`,并让模型配置列表、模型下拉、图片自动索引矩阵立即回到普通 embedding 表现。 +7. 文案可复用 `支持图片识别` label;tip 建议新增 embedding 专用 key,说明“开启后该 embedding 模型可接收图片输入并用于图片向量索引/图搜图”。 +8. `vision` 在 LLM 和 embedding 上语义不同:LLM 是图片理解,embedding 是图片向量化。必须通过模型类型和 helper 判断,别一看字段名一样就到处裸用,后面排障基本就是开盲盒。 + +### 4.4 分块弹窗数据索引 UI 细节 + +1. 右侧标题仍为 `数据索引(n)`,数量按当前可展示索引数量计算。 +2. `默认索引` 是基础索引,不展示删除入口,不允许被删除。 +3. 除 `默认索引` 外,其他索引都展示删除入口并允许删除,包括 `多模态图片索引`、`推测问题索引`、`摘要索引`、自定义索引等。 +4. `多模态图片索引` 的索引内容不可见,不展示向量、图片 URL、S3 key、原始索引文本或任何内部字段。 +5. `多模态图片索引` 展开后只展示 UI 默认说明文案:`已通过多模态模型生成图片向量,支持以图搜图`。 +6. 删除非默认索引时应有确认或防误触处理;删除成功后右侧索引数量和卡片列表立即刷新。 +7. 删除动作必须同步后端索引数据,不能只在前端隐藏卡片,否则搜索仍可能命中已删除索引,这种“假删除”后面排查要命。 +8. 删除 `多模态图片索引` 后默认仅删除当前索引;若需要“删除后自动重建”,需单独向产品确认,本期文档不默认加这个隐藏行为。 + +### 4.5 搜索测试页 UI 细节 + +1. 左侧标题为 `输入测试内容`。 +2. 标题右侧为 `搜索配置` 按钮,使用 gear icon,点击打开现有搜索参数弹窗。 +3. 输入区域是一个大 textarea 容器,placeholder 为 `输入需要测试的内容`。 +4. 图片上传按钮放在输入框左下角,使用图片图标按钮,不使用文字按钮。 +5. 若当前知识库 `!isImageEmbeddingModel(dataset.vectorModel) && !dataset.vlmModel`,图片上传按钮 disabled;hover 提示 `请配置图片理解模型或多模态索引模型`。 +6. 图片上传只支持图片,不支持文件;文件选择器优先限制为图片类型,拖拽/粘贴/选择到非图片文件时直接过滤。 +7. 支持仅上传图片不输入文字直接测试;只要待搜索图片列表非空,`测试` 按钮可用。 +8. 图片数量上限为 10 张;选择后超过 10 张时提示 `最多支持上传10张图片`,超出的图片不加入列表。 +9. 图片大小和格式限制跟随系统现有上传规则;不符合系统规则的图片直接过滤,不进入上传中状态。 +10. 搜索测试上传图片必须设置 3 小时过期时间,前端不暴露配置项;后端上传时写入 TTL。 +11. 已上传图片在输入框顶部横向排列,缩略图尺寸固定,避免 textarea 高度被图片加载状态反复撑开。 +12. 单张图片 hover 或选中态展示右上角删除按钮,点击后从待搜索图片列表移除。 +13. 图片上传中展示独立的上传中卡片,卡片尺寸与缩略图一致,中间显示 loading 圆环。 +14. 输入文字区域位于图片缩略图下方;没有图片时,placeholder 仍从输入框上方自然显示。 +15. `测试` 按钮放在输入框下方,撑满左侧区域。 +16. `测试历史` 标题前不展示 icon。 +17. 历史项不展示搜索模式 icon/title,只展示内容摘要、图片 token、时间或删除。 +18. 图片检索历史用 `[图片]` token 表示图片输入;多张图片显示多个 `[图片]` token,超出宽度按现有文本省略规则处理。 +19. 鼠标 hover 到含图片的历史项时,在历史项下方或右下方弹出图片缩略图浮层。 +20. 缩略图浮层展示该次检索对应的图片缩略图,横向排列,尺寸固定,浮层有白底、圆角、阴影和边框。 +21. 鼠标移出历史项和浮层后关闭缩略图浮层;hover 删除按钮时仍应优先展示删除操作,不要被浮层挡住。 +22. 纯文本历史不展示图片缩略图浮层。 +23. 中间区域保留 `测试参数` 和 `测试结果`。 +24. 右侧知识库信息栏保持现有能力,不在本期做大改。 + +### 4.6 工作流节点 UI 细节 + +1. `知识库搜索` 节点的 `检索内容` valueType 改为 `WorkflowIOValueTypeEnum.arrayString`。 +2. 同一个输入框允许同时引用: + - `流程开始 > 用户问题` + - `流程开始 > 文件链接` +3. 默认新建节点时只自动带上 `流程开始 > 用户问题`;不默认带 `流程开始 > 文件链接`。 +4. 旧工作流若仍存 string 值,前端展示和后端运行都要兼容。 +5. 后端归一化时将图片文件链接拆到 `queryImageUrls`,普通文本拆到 `textQueries`。 +6. 文件链接变量里可能包含 PDF、docx、xlsx、txt、音视频等非图片文件;这些链接必须在后端过滤,不参与图搜图,也不要降级塞进文本检索。 +7. 过滤非图片文件链接时不要弹前端错误。节点响应可记录 `filteredFileCount`,日志只记录数量和类型,不记录完整 URL。 + +### 4.7 图片自动索引配置逻辑 + +该部分不是 UI 图,不需要放截图。实现时必须按下面的配置矩阵控制 `imageIndex` 复选框、tooltip、QuestionTip 和最终提交值。 + +| 场景 | 条件判断 | Checkbox | Tooltip | QuestionTip | 提交值处理 | +|---|---|---|---|---|---| +| 非商业版 | `!feConfigs?.isPlus` | disabled | 商业版提示 | `请升级商业版后使用该功能` | 强制 `imageIndex=false` | +| 多模态索引 + 有 VLM | `isImageEmbeddingModel(dataset.vectorModel) && dataset.vlmModel` | enabled | 空 | `为文档中的图片生成图片向量索引和文本描述索引,支持以图搜图` | 尊重用户勾选值 | +| 多模态索引 + 无 VLM | `isImageEmbeddingModel(dataset.vectorModel) && !dataset.vlmModel` | enabled | 空 | `使用多模态模型为图片生成向量索引,支持以图搜图` | 尊重用户勾选值 | +| 普通索引 + 有 VLM | `!isImageEmbeddingModel(dataset.vectorModel) && dataset.vlmModel` | enabled | 空 | `调用 VLM 自动标注文档里的图片,并生成文本描述索引` | 尊重用户勾选值 | +| 普通索引 + 无 VLM | `!isImageEmbeddingModel(dataset.vectorModel) && !dataset.vlmModel` | disabled | 同 QuestionTip | `需配置图片理解模型,或切换多模态向量模型后,方可启用` | 强制 `imageIndex=false` | + +实现要求: + +1. 前端禁用时如果当前表单里 `imageIndex=true`,必须立即 `setValue('imageIndex', false)`,别出现“灰了但提交还是 true”的离谱状态。 +2. 后端接收 `chunkSettings.imageIndex` 时也要复核同一套条件,前端禁用不是安全边界。 +3. 多模态索引 + 无 VLM 时允许开启图片自动索引,但只生成图片向量索引,不生成 VLM 文本描述索引。 +4. 普通索引 + 有 VLM 时允许开启图片自动索引,但只生成 VLM 文本描述索引,不生成图片向量索引。 +5. 搜索阶段不额外报错:普通索引 + 无 VLM 的行为和现有逻辑一致,配置阶段负责禁止用户创建“以为有图片索引但实际没有”的状态。 +6. 当知识库 `vectorModel` 或 `vlmModel` 发生切换时,前端和后端都要重新执行本矩阵,更新 `imageIndex` 可用性和提示文案。 +7. 从普通 embedding 切到多模态 embedding 后,后续训练/重建应切到“图片向量索引 + 可选 VLM 文本描述索引”的生成方式。 +8. 从多模态 embedding 切到普通 embedding 后,后续训练/重建不得继续生成 `imageEmbedding` 图片向量索引;如果没有 VLM,则必须强制 `imageIndex=false`。 +9. 模型切换后必须全库重建或明确标记待重建,否则存量向量仍由旧 embedding/VLM 模型组合生成,搜索质量和配置会对不上。 + +## 5. 日志与可观测性 + +| 触发点 | 日志级别 | category | 字段 | 备注 | +|---|---|---|---|---| +| 图片 embedding 生成失败 | error | dataset embedding | `teamId/datasetId/collectionId/dataId/model/indexType/error` | 不记录图片内容 | +| 图片上传失败 | warn/error | dataset upload | `teamId/datasetId/fileCount/mimeType/size/error` | 文件名脱敏 | +| 工作流输入归一化异常/文件过滤 | warn/info | dataset search | `teamId/datasetIds/inputCount/textCount/imageCount/filteredFileCount/error` | 不记录完整输入和完整 URL | +| 模型不支持图片索引 | warn | dataset training | `teamId/datasetId/model/vision` | 用于排查配置 | +| 向量维度不匹配 | error | vector | `model/vectorLength/expectedLength/datasetId` | 不记录向量数组;维度约束沿用现有向量写入链路 | + +注意事项: + +- 统一使用 `@fastgpt/service/common/logger`。 +- 不记录 token、密码、密钥、base64、完整私有 URL、完整用户问题。 +- 搜索历史只保存摘要、图片数量和受控缩略图引用,不保存 base64 或完整私有预签名 URL。 + +## 6. 文档更新提醒(必填) + +| 文档路径 | 文档类型 | 更新原因 | 计划更新内容 | 负责人 | 截止时间 | 状态 | +|---|---|---|---|---|---|---| +| `document/content/openapi/dataset.mdx` | OpenAPI 中文 | 搜索测试 API 增加图片输入 | `queryImageUrls`、本地上传说明、纯图片搜索、最多 10 张、图文混合示例 | 开发实现者 | 实现完成前 | 已更新 | +| `document/content/openapi/dataset.en.mdx` | OpenAPI 英文 | 中文同步 | 英文参数说明和示例,包含纯图片搜索和 10 张限制 | 开发实现者 | 实现完成前 | 已更新 | +| `document/content/introduction/guide/knowledge_base/dataset_engine.mdx` | 功能中文 | 新增图搜图与多模态图片索引 | 创建知识库、图片自动索引、搜索测试说明 | 开发实现者 | 实现完成前 | 已更新 | +| `document/content/introduction/guide/knowledge_base/dataset_engine.en.mdx` | 功能英文 | 中文同步 | 同步 image-to-image search、多模态图片索引和搜索测试说明 | 开发实现者 | 实现完成前 | 已更新 | +| `document/content/introduction/guide/dashboard/workflow/dataset_search.mdx` | 功能中文 | 检索内容改 `Array` | 说明同时接用户问题和文件链接,并明确只有图片文件链接参与检索,其他文件链接会被过滤 | 开发实现者 | 实现完成前 | 已更新 | +| `document/content/introduction/guide/dashboard/workflow/dataset_search.en.mdx` | 功能英文 | 中文同步 | 同步 `Array`、图片链接参与检索和非图片文件过滤说明 | 开发实现者 | 实现完成前 | 已更新 | + +## 7. 文档 i18n 实施说明(命中时必填) + +### 7.1 翻译范围识别 + +- 自动检测命令: + - `git diff --name-only` + - `git diff --cached --name-only` +- 手动指定路径: + - `document/content/openapi/dataset.mdx` + - `document/content/openapi/dataset.en.mdx` + - 知识库功能文档中文/英文对应文件 + - 工作流知识库搜索节点中文/英文对应文件 + +### 7.2 文件映射与动作 + +| 中文文件 | 英文文件 | 类型 | 动作 | 状态 | +|---|---|---|---|---| +| `document/content/openapi/dataset.mdx` | `document/content/openapi/dataset.en.mdx` | mdx | 更新 | 已完成 | +| `document/content/introduction/guide/knowledge_base/dataset_engine.mdx` | `document/content/introduction/guide/knowledge_base/dataset_engine.en.mdx` | mdx | 更新 | 已完成 | +| `document/content/introduction/guide/dashboard/workflow/dataset_search.mdx` | `document/content/introduction/guide/dashboard/workflow/dataset_search.en.mdx` | mdx | 更新 | 已完成 | + +### 7.3 翻译约束清单 + +- 保持不变:import、图片路径、URL、HTML/JSX 结构、表格结构、代码块字段名。 +- 必须翻译:frontmatter、正文、表格文字、中文注释。 +- 术语建议: + - 图搜图:`image-to-image search` + - 多模态图片索引:`multimodal image index` + - 图片自动索引:`automatic image indexing` + - 搜索配置:`Search configuration` + +### 7.4 缺失文件与提醒 + +| 缺失英文文件 | 对应中文文件 | 处理建议 | +|---|---|---| +| 无 | 知识库功能文档 | 已复用现有中英文对应文件 | +| 无 | 工作流知识库搜索节点文档 | 已复用现有中英文对应文件 | + +## 8. 测试与验证 + +测试规范来源:`references/testing-standards.md`。 + +### 8.1 测试文件映射(必填) + +| 源文件路径 | 文件类型 | 目标测试文件路径 | 是否跳过 | 跳过理由 | +|---|---|---|---|---| +| `packages/global/core/ai/model.schema.ts` | packages | `test/cases/global/core/ai/model.schema.test.ts` | 否 | schema 兼容性需测 | +| `packages/service/core/ai/model.ts` | packages | `test/cases/service/core/ai/model.test.ts` | 否 | helper 需测 | +| `projects/app/src/pages/api/core/ai/model/update.ts` | projects | `projects/app/test/pages/api/core/ai/model/update.test.ts` | 否 | embedding `vision` 保存需测 | +| `projects/app/src/pages/api/core/ai/model/list.ts` | projects | `projects/app/test/pages/api/core/ai/model/list.test.ts` | 否 | embedding `vision` 返回需测 | +| `projects/app/src/pageComponents/account/model/AddModelBox.tsx` | projects | `projects/app/test/pageComponents/account/model/AddModelBox.test.tsx` | 否 | 支持图片识别开关需测 | +| `projects/app/src/pageComponents/account/model/ModelConfigTable.tsx` | projects | `projects/app/test/pageComponents/account/model/ModelConfigTable.test.tsx` | 否 | 模型配置列表标签需测 | +| `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | projects | `projects/app/test/pageComponents/dataset/list/CreateModal.test.tsx` | 否 | 创建知识库字段提示文案需测 | +| `projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx` | projects | `projects/app/test/pageComponents/dataset/detail/Form/CollectionChunkForm.test.tsx` | 否 | 图片自动索引矩阵需测 | +| `packages/service/core/ai/embedding/index.ts` | packages | `test/cases/service/core/ai/embedding/index.test.ts` | 否 | 图片 embedding 入参和错误分支 | +| `projects/app/src/service/core/dataset/data/controller.ts` | projects | `projects/app/test/service/core/dataset/data/controller.test.ts` | 否 | 图片/文本索引分流 | +| `projects/app/src/service/core/dataset/queues/generateVector.ts` | projects | `projects/app/test/service/core/dataset/queues/generateVector.test.ts` | 否 | 重建分流 | +| `packages/service/core/dataset/search/controller.ts` | packages | `test/cases/service/core/dataset/search/controller.test.ts` | 否 | 图文搜索核心 | +| `projects/app/src/pages/api/core/dataset/searchTest.ts` | projects | `projects/app/test/pages/api/core/dataset/searchTest.test.ts` | 否 | API schema 与空输入 | +| `packages/service/core/workflow/dispatch/dataset/search.ts` | packages | `test/cases/service/core/workflow/dispatch/dataset/search.test.ts` | 否 | `Array` 归一化 | +| `projects/app/src/pageComponents/dataset/detail/Test.tsx` | projects | `projects/app/test/pageComponents/dataset/detail/Test.test.tsx` | 否 | 搜索测试页 UI | +| `projects/app/src/web/core/dataset/store/searchTest.ts` | projects | `projects/app/test/web/core/dataset/store/searchTest.test.ts` | 否 | 搜索历史兼容 | +| `projects/app/src/components/Select/AIModelSelector.tsx` | projects | `projects/app/test/components/Select/AIModelSelector.test.tsx` | 否 | 标签顺序 | +| `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | projects | `projects/app/test/pageComponents/dataset/detail/InputDataModal.test.tsx` | 否 | 分块弹窗样式、索引可见性和删除交互 | +| `packages/global/core/dataset/constants.ts` | packages | N/A | 是 | 纯 enum/map,随引用测试覆盖 | +| `packages/global/core/dataset/data/constants.ts` | packages | N/A | 是 | 纯 enum/map,随引用测试覆盖 | + +### 8.2 自动化测试设计 + +| 类型 | 用例 | 预期结果 | +|---|---|---| +| 单元测试 | 老 embedding 模型无 `vision` | helper 返回 text-only | +| 单元测试 | 多模态模型 `vision=true` | 支持 image | +| 单元测试 | 模型更新 API 保存 embedding `vision=true` | 列表 API 返回相同能力,模型下拉可识别为多模态 | +| 单元测试 | 模型更新 API 关闭支持图片识别 | `vision=false` 或缺省,模型按 text-only 判断 | +| 单元测试 | LLM 与 embedding 均存在 `vision` | helper 按模型类型隔离语义,embedding 场景只判断图片向量化能力 | +| 单元/i18n 检查 | 创建知识库索引模型 tip key | 中文文案为 `索引模型可以将知识库内容转成向量,用于进行语义检索。注意,不同索引模型的知识库无法同时查询,切换索引模型需重建全量向量索引,请慎重选择。`,en/zh-Hant key 不缺失 | +| 单元/i18n 检查 | 多模态 hover tip key | 中文文案为 `多模态索引模型可以给图片生成向量。`,en/zh-Hant key 不缺失 | +| 单元/i18n 检查 | 图片理解模型 tip key | 中文文案为 `自动标注文档里的图片并生成文本描述,辅助文本检索`,en/zh-Hant key 不缺失 | +| 单元测试 | 图片自动索引非商业版 | disabled,商业版提示 | +| 单元测试 | 多模态 + 有 VLM | enabled,提示图片向量 + 文本描述 | +| 单元测试 | 多模态 + 无 VLM | enabled,提示图片向量 | +| 单元测试 | 普通索引 + 有 VLM | enabled,提示 VLM 文本描述 | +| 单元测试 | 普通索引 + 无 VLM | disabled,提示配置 VLM 或切换模型 | +| 单元测试 | 普通 embedding 切多模态 embedding | 知识库进入全库重建/待重建状态,后续训练策略切换为图片向量索引 | +| 单元测试 | 多模态 embedding 切普通 embedding 且无 VLM | 强制 `imageIndex=false`,知识库进入全库重建/待重建状态,后续训练策略不再生成图片向量索引 | +| 单元测试 | 图文文档初次导入,`trainingData.q` 含 markdown 图片且模型 `vision=true` | `insertData()` 建索引前追加 `DatasetDataIndexTypeEnum.imageEmbedding`,图片走 `getVectorsByImage` | +| 单元测试 | 图文文档重建,`trainingData.q` 不含图片但 `trainingData.data.q` 含 markdown 图片 | `rebuildData()` 仍追加 `imageEmbedding`,避免 VLM 后续改写训练文本导致漏图 | +| 单元测试 | 图文文档已有相同 `imageEmbedding` | 不重复追加同一图片 URL | +| 单元测试 | 普通 embedding 或 `collection.imageIndex=false` 的图文文档 | 不追加 `imageEmbedding`,只保留文本类索引 | +| 单元测试 | 搜索测试空输入 | text 和 queryImageUrls 都空时报错 | +| 单元测试 | 搜索测试仅图片输入 | text 为空、queryImageUrls 有值时允许搜索 | +| 单元测试 | 搜索测试图片超过 10 张 | API 校验失败,前端提示 `最多支持上传10张图片` | +| 单元测试 | 搜索测试上传非图片文件 | 非图片文件被过滤,不生成 queryImageUrls | +| 单元测试 | 搜索测试上传超出系统格式/大小限制的图片 | 直接过滤,不进入待上传/待搜索列表 | +| 单元测试 | 搜索测试上传图片过期时间 | 上传成功后写入 S3 TTL,`expiredTime` 为当前时间后 3 小时 | +| 单元/组件测试 | 搜索测试无图片搜索能力 | 普通 embedding 且无 VLM 时图片上传按钮 disabled,hover 展示 `请配置图片理解模型或多模态索引模型` | +| 单元测试 | 多模态 embedding 纯图片搜索 | 走 image modality,检索 `imageEmbedding` | +| 单元测试 | 多模态 embedding + VLM 纯图片搜索 | 同时走图片向量召回和查询图片 VLM caption 文本召回 | +| 单元测试 | 多模态 embedding + VLM 图文混合搜索 | 用户文本、图片向量、图片 caption 三路召回并 RRF 合并 | +| 单元测试 | 普通 embedding + VLM 纯图片搜索 | 查询图片先转 caption,再走普通文本检索 | +| 单元测试 | 普通 embedding + VLM 图文混合搜索 | 用户文本和图片 caption 作为多路文本 query 合并 | +| 单元测试 | 普通 embedding + 无 VLM 纯图片搜索 | 不做图片检索,返回空召回结果,不抛错 | +| 单元测试 | 普通 embedding + 无 VLM 图文混合搜索 | 忽略图片分支,只使用用户文本检索 | +| 单元测试 | 工作流 `userChatInput` 为 string | 归一化为文本 query | +| 单元测试 | 工作流 `userChatInput` 为 array,含用户问题和图片链接 | 拆成 textQueries 和 queryImageUrls | +| 单元测试 | 工作流 `userChatInput` 为 array,含 PDF/docx/xlsx 等非图片文件链接 | 非图片文件链接被过滤,`filteredFileCount` 增加,不进入 textQueries/queryImageUrls | +| 单元测试 | 工作流 `userChatInput` 只有非图片文件链接 | 返回空检索结果,不抛错,nodeResponse/log 记录过滤数量 | +| 单元测试 | 多图搜索 | 每张图单独召回,RRF 合并 | +| 单元测试 | 重建图片索引 | 图片索引走图片 embedding | +| 组件测试 | 模型下拉标签 | `Beta` 在 `多模态` 前 | +| 组件测试 | 模型下拉多模态 hover | hover `多模态` 标签展示 `多模态索引模型可以给图片生成向量。`,hover `Beta` 不展示该说明 | +| 组件测试 | 创建知识库弹窗 QuestionTip | `索引模型` 和 `图片理解模型` 问号分别展示固定文案 | +| 组件测试 | embedding 模型设置表单 | `支持图片识别` 默认关闭;打开后提交 `vision=true`;关闭后提交 `vision=false` 或缺省 | +| 组件测试 | 模型配置列表标签 | `vision=true` 的 embedding 模型展示 `Beta`、`多模态`,普通 embedding 不展示多模态 | +| 组件测试 | 搜索测试图片上传 UI | 图片按钮位于输入框左下角;缩略图、删除按钮、上传中卡片按设计展示 | +| 组件测试 | 搜索测试纯图片 | 只上传图片不输入文字时,测试按钮可用并提交 queryImageUrls | +| 组件测试 | 搜索测试图片按钮禁用 | 普通 embedding 且无 VLM 时图片按钮不可点击,hover 展示 `请配置图片理解模型或多模态索引模型` | +| 组件测试 | 搜索测试图片数量限制 | 选择第 11 张图片时提示 `最多支持上传10张图片`,列表最多保留 10 张 | +| 组件测试 | 搜索测试历史项 | 不显示搜索模式 icon/title | +| 组件测试 | 搜索测试图片历史 hover | 含图片历史显示 `[图片]` token;hover 后展示缩略图浮层;纯文本历史不展示浮层 | +| 组件测试 | 搜索配置按钮 | 点击打开参数弹窗 | +| 组件测试 | 图片分块弹窗 | 展示图片预览和多模态图片索引卡 | +| 组件测试 | 多模态图片索引展开 | 不展示内部索引内容,只展示默认说明文案 | +| 组件测试 | 数据索引删除入口 | 默认索引不展示删除入口;多模态图片索引、推测问题索引、摘要索引、自定义索引展示删除入口 | +| 单元/组件测试 | 删除非默认索引 | 删除成功后更新 `indexes[]` 和向量索引状态,右侧数量刷新 | + +### 8.3 场景覆盖核对 + +| 场景 | 是否覆盖 | 对应用例/describe | +|---|---|---| +| 基础场景 | 是 | 纯文本、纯图、图文混合 | +| 复杂场景 | 是 | 多图、多路 RRF、工作流同槽位多引用 | +| 边界值 | 是 | 空输入、纯图片输入、图片超过 10 张、非图片文件过滤、系统格式/大小限制过滤、模型不支持 image、无 VLM | +| 安全边界 | 是 | 不记录 base64/完整私有 URL;历史仅摘要 | +| 异常场景 | 是 | 上传失败、图片读取失败、embedding API 异常、向量维度不匹配 | +| 兼容场景 | 是 | 旧模型无 `vision`、旧工作流 string、旧搜索历史 | + +### 8.4 执行命令与结果 + +开发中优先局部测试: + +```shell +pnpm test test/cases/service/core/ai/model.test.ts +pnpm test projects/app/test/pages/api/core/ai/model/update.test.ts +pnpm test projects/app/test/pages/api/core/ai/model/list.test.ts +pnpm test projects/app/test/pageComponents/account/model/AddModelBox.test.tsx +pnpm test projects/app/test/pageComponents/account/model/ModelConfigTable.test.tsx +pnpm test test/cases/service/core/ai/embedding/index.test.ts +pnpm test test/cases/service/core/dataset/search/controller.test.ts +pnpm test test/cases/service/core/workflow/dispatch/dataset/search.test.ts +pnpm test projects/app/test/pages/api/core/dataset/searchTest.test.ts +pnpm test projects/app/test/pageComponents/dataset/detail/Test.test.tsx +pnpm test projects/app/test/pageComponents/dataset/detail/InputDataModal.test.tsx +``` + +最终合并前: + +```shell +pnpm test +pnpm lint +``` + +| 命令 | 结果 | 覆盖率 | 备注 | +|---|---|---|---| +| `pnpm run build:sdks` | 通过 | N/A | SDK 构建完成;Node 20 有 deprecated 提示,不影响结果 | +| `pnpm exec tsc --noEmit --pretty false --incremental false --project projects/app/tsconfig.json` | 通过 | N/A | app TypeScript 检查通过 | +| `pnpm exec prettier --config ./.prettierrc.js --check ` | 通过 | N/A | 已覆盖本次改动的 TS/TSX/JSON/MDX/Markdown 文件 | +| `pnpm exec eslint --ignore-path .eslintignore ` | 通过 | N/A | 仅有 monorepo root 下执行导致的 Pages directory/React version 环境警告 | +| `git diff --check` | 通过 | N/A | 无 whitespace error | +| i18n JSON parse | 通过 | N/A | `common/dataset/file/account/account_model` 三语言 JSON 均可解析 | +| `pnpm --filter @fastgpt/service test -- test/core/ai/embedding/index.test.ts` | 通过 | Statements 32.31% | 实际执行了 `@fastgpt/service` 测试包:86 个测试文件通过、1 个跳过;2109 个测试通过、26 个跳过 | +| `pnpm exec tsc --noEmit --pretty false --incremental false --project projects/app/tsconfig.json` | 通过 | N/A | 反向核对补齐重建链路、搜索测试页 UI 和工作流归一化后再次通过 | +| `pnpm --filter @fastgpt/admin typecheck` | 通过 | N/A | `pro/admin` 图片索引分流改动后再次通过 | +| 搜索测试页 UI 反向核对 | 已补齐 | N/A | `搜索配置` 按钮、输入标题、图片缩略图顶部、上传按钮、测试按钮下移、历史标题去 icon 已按文档修正 | +| 工作流输入归一化反向核对 | 已补齐 | N/A | 避免无后缀普通 URL 被 parser 误判为非图片文件后过滤 | +| 模型切换后图片自动索引反向核对 | 已补齐 | N/A | 切到普通 embedding 且无 VLM 时,后端清理 dataset/collection 的 `imageIndex=false`,避免后续重训继续按图片索引模式入队 | + +pro/admin 补充验证: + +| 范围 | 状态 | 说明 | +|---|---|---| +| `pro/admin/src/service/core/dataset/training/imageParse.ts` | 已核查复用 | 图片数据集有 VLM 时继续走 VLM 文本描述,再由 chunk 入库链路按多模态模型自动补 `imageEmbedding` | +| `pro/admin/src/service/core/dataset/training/imageIndex.ts` | 已补齐职责边界 | 文档 Markdown 图片仅由 VLM 生成文本描述索引,并保留原始 `q` 中的 markdown 图片引用;`imageEmbedding` 由后续 `generateVector` 建索引阶段统一补齐 | +| `pnpm --filter @fastgpt/admin typecheck` | 通过 | pro/admin TypeScript 检查通过 | + +### 8.5 手工验证 + +| 场景 | 操作步骤 | 预期结果 | +|---|---|---| +| embedding 模型支持图片开关 | 进入模型配置页,打开某个 embedding 模型设置 | `支持图片识别` 默认关闭;打开保存后该模型配置写入 `vision=true`;关闭保存后 `vision=false` 或缺省 | +| 模型配置列表标签 | 保存打开图片识别的 embedding 模型后返回模型列表 | 该模型展示 `Beta`、`多模态` 标签;关闭后 `多模态` 标签消失 | +| 创建知识库模型提示 | 打开创建通用知识库弹窗,hover `索引模型` 问号和 `图片理解模型` 问号 | 分别展示本文档固定文案 | +| 创建知识库模型下拉 | 打开创建通用知识库弹窗,展开索引模型 | 多模态模型展示 `Beta`、`多模态`,顺序正确;hover `多模态` 标签展示固定说明 | +| 图片自动索引非商业版 | 模拟 `feConfigs.isPlus=false` | 复选框禁用,提示升级商业版 | +| 图片自动索引多模态无 VLM | 选择多模态索引模型且不配 VLM | 复选框可用,提示生成图片向量索引 | +| 图片自动索引普通无 VLM | 选择普通索引模型且不配 VLM | 复选框禁用,提示配置 VLM 或切换多模态 | +| 索引模型切换 | 将知识库索引模型从普通 embedding 切到多模态 embedding,再切回普通 embedding | 图片自动索引状态和提示实时变化;切回普通且无 VLM 时 `imageIndex` 被清理为 false;知识库进入全库重建/待重建状态 | +| DOCX 图文分块初次导入 | 使用多模态 embedding + VLM,导入包含内嵌图片的 DOCX 并开启图片自动索引 | 同一分块同时存在 VLM 文本索引和 `多模态图片索引`;图片本体可参与图搜图 | +| PDF/HTML/Markdown 图文分块导入 | 使用能产出 markdown 图片的 PDF 解析服务、HTML 或 Markdown 导入并开启图片自动索引 | 不按文件格式分支,只要分块 `q` 含 markdown 图片,就生成 `imageEmbedding` | +| 图片分块弹窗 | 打开图片数据分块 | 左侧图片预览和图片内容,右侧数据索引卡 | +| 多模态图片索引内容 | 展开右侧 `多模态图片索引` 卡片 | 不展示向量、图片 URL、S3 key 或原始索引内容,只展示 `已通过多模态模型生成图片向量,支持以图搜图` | +| 数据索引删除 | 在分块弹窗右侧查看默认索引和其他索引 | 默认索引无删除入口;除默认索引外其他索引均可删除,删除后卡片和数量刷新 | +| 搜索测试纯图 | 点击输入框左下角图片按钮上传本地图片后点击测试 | 图片缩略图展示正常,能以图片参与检索 | +| 搜索测试纯图片无文字 | 在支持图片搜索的知识库中只上传图片,不输入文字,点击测试 | 前端允许提交,后端接收 `queryImageUrls` 并执行图片检索 | +| 搜索测试无图片搜索能力 | 使用普通 embedding 且无 VLM 的知识库打开搜索测试页 | 图片上传按钮 disabled;hover 展示 `请配置图片理解模型或多模态索引模型`;文本搜索仍可用 | +| 搜索测试图片数量限制 | 连续选择超过 10 张图片 | 前端提示 `最多支持上传10张图片`,待搜索图片列表最多 10 张 | +| 搜索测试非图片文件 | 通过选择/拖拽/粘贴尝试加入 PDF、docx、xlsx 等文件 | 非图片文件直接过滤,不出现在待搜索图片列表 | +| 搜索测试图片过期时间 | 上传一张搜索测试图片后检查 TTL 记录 | 上传对象过期时间为上传后 3 小时,历史/store 不保存 base64 或完整私有 URL | +| 搜索测试系统限制过滤 | 上传系统不支持格式或超出系统大小限制的图片 | 图片直接过滤,不进入上传中卡片和 queryImageUrls | +| 搜索测试图文混合 | 输入文本并上传图片 | 文本和图片召回合并 | +| 多模态 + VLM 图文混合召回 | 使用多模态 embedding 且配置 VLM 的知识库,输入文字和图片 | 用户文本、图片向量、图片 caption 三路召回;同时命中图片索引和 VLM 文本索引的数据排序更靠前 | +| 普通 embedding + VLM 图片召回 | 使用普通 embedding 且配置 VLM 的知识库,只输入图片 | 图片先转文字,再走文本检索 | +| 普通 embedding 无 VLM 图片召回 | 使用普通 embedding 且无 VLM 的知识库,只输入图片或图文混合 | 纯图片返回空结果;图文混合只按文字检索 | +| 测试历史 | 连续测试后查看历史 | 历史标题和历史项无前置模式 icon/title | +| 图片历史 hover | 鼠标移到包含 `[图片]` 的历史项上 | 历史项下方弹出对应图片缩略图浮层,鼠标移出后消失 | +| 工作流节点默认值 | 新建知识库搜索节点 | 检索内容默认只引用 `流程开始 > 用户问题`,不默认引用文件链接 | +| 工作流节点手动加文件链接 | 在知识库搜索节点检索内容中手动追加文件链接引用 | 运行时拆出文本和图片,返回 quoteQA | +| 工作流非图片文件链接 | 检索内容接入用户问题、图片链接、PDF 链接、docx 链接 | 用户问题进入文本检索,图片链接进入图搜图,PDF/docx 链接被过滤且不报错 | + +## 9. 质量自检清单 + +- [x] 旧文档未覆盖。 +- [x] embedding 模型复用 `vision` 作为图片向量化能力字段,不新增 `modalities`。 +- [x] embedding 模型 `支持图片识别` 开关默认关闭,打开才保存 `vision=true`。 +- [x] `vision` 在 LLM 和 embedding 上通过模型类型/helper 隔离语义,没有在业务里裸读导致混用。 +- [x] 模型下拉 `Beta` 在 `多模态` 前。 +- [x] 创建知识库 `索引模型` 问号、`图片理解模型` 问号和 `多模态` 标签 hover 使用固定 i18n 文案。 +- [x] “检索内容”本身为 `Array`,不是新增外露 `fileUrlList` 字段替代。 +- [x] 工作流知识库搜索只把图片文件链接放入 `queryImageUrls`,PDF/docx/xlsx 等非图片文件链接被后端过滤,且普通无后缀 URL 不被误过滤。 +- [x] 搜索召回按知识库模型能力分支:多模态 embedding 直接图/文检索,普通 embedding 只能通过 VLM caption 处理图片。 +- [x] 多模态 embedding + VLM 时,图片向量召回和 VLM caption 文本召回都参与 RRF;同一数据多路命中排序权重提升。 +- [x] 普通 embedding + 无 VLM 时不做图片检索,纯图片返回空召回,图文混合只用文字。 +- [x] 搜索测试支持本地上传图片,且图片按钮、缩略图、删除按钮、上传中卡片符合最新 UI。 +- [x] 搜索测试只支持上传图片,不支持文件;允许纯图片无文字搜索。 +- [x] 普通 embedding 且无 VLM 时搜索测试图片上传按钮禁用,hover 提示 `请配置图片理解模型或多模态索引模型`。 +- [x] 搜索测试最多 10 张图片,超过提示 `最多支持上传10张图片`。 +- [x] 搜索测试图片大小和格式跟随系统上传规则,不符合规则的输入直接过滤。 +- [x] 搜索测试上传图片对象设置 3 小时过期时间,并写入 S3 TTL 清理链路。 +- [x] 图片检索历史显示 `[图片]` token,hover 后展示图片缩略图浮层,纯文本历史不展示浮层。 +- [x] 多模态图片索引内容不可见,只展示默认说明文案。 +- [x] 默认索引不可删除;除默认索引外,其他索引都可以删除。 +- [x] 图片自动索引 5 场景矩阵全部实现。 +- [x] 普通索引 + 无 VLM 时图片自动索引禁用且提交值为 false。 +- [x] 多模态 + 无 VLM 时仍可生成图片向量索引。 +- [x] embedding/索引模型或 VLM 切换后,图片自动索引可用性、后续训练方式和全库重建/待重建状态同步更新;切到普通 embedding 且无 VLM 时后端清理 `imageIndex=false`。 +- [x] 图片向量索引和 VLM 图片文本索引不会混淆。 +- [x] 重建索引按索引类型分流。 +- [x] API 使用 zod schema parse。 +- [x] 工作流旧 string 输入兼容。 +- [x] 日志不记录 base64、完整私有 URL、完整用户输入。 +- [x] i18n 覆盖 zh-CN/en/zh-Hant。 +- [x] OpenAPI 和功能文档中英文同步。 +- [x] 局部测试和最终检查通过。 + +## 10. 发布与回滚 + +### 10.1 发布步骤 + +1. 合并模型配置 `vision`,先确认现网 embedding 模型缺省字段可解析。 +2. 发布后端搜索核心和训练分流,默认不影响 text-only 模型。 +3. 发布前端 UI,开启多模态标签、图片自动索引矩阵、搜索测试图片上传。 +4. 更新文档和 OpenAPI。 +5. 灰度验证多模态模型数据集的图片导入、搜索测试、工作流搜索。 + +### 10.2 回滚触发条件 + +- 图片 embedding 大面积失败或向量维度不兼容。 +- 工作流 `Array` 兼容旧节点出现运行异常。 +- 搜索测试图片上传存在权限或隐私风险。 +- 图片自动索引误启用导致普通索引无 VLM 的知识库生成异常任务。 + +### 10.3 回滚动作 + +1. 前端隐藏搜索测试图片上传和多模态图片索引入口。 +2. 模型配置关闭 embedding `vision`,前端自动回到普通模型表现。 +3. 后端保留输入兼容层,但关闭图片 query 分支。 +4. 停止新生成 `imageEmbedding` 索引,保留已有索引不影响文本检索。 +5. 若工作流新模板有问题,回退模板但保留运行时兼容,避免已有应用崩溃。 diff --git "a/.codex/design/\345\233\276\346\220\234\345\233\276-\345\275\223\345\211\215\351\234\200\346\261\202-\351\234\200\346\261\202\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/.codex/design/\345\233\276\346\220\234\345\233\276-\345\275\223\345\211\215\351\234\200\346\261\202-\351\234\200\346\261\202\350\256\276\350\256\241\346\226\207\346\241\243.md" new file mode 100644 index 000000000000..35ea3e0393c6 --- /dev/null +++ "b/.codex/design/\345\233\276\346\220\234\345\233\276-\345\275\223\345\211\215\351\234\200\346\261\202-\351\234\200\346\261\202\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -0,0 +1,515 @@ +# 需求设计文档 + +## 0. 文档标识 + +- 任务前缀:`图搜图-当前需求` +- 文档文件名:`图搜图-当前需求-需求设计文档.md` +- 更新时间:2026-05-01 +- 文档状态:`v2.0 反向核对完成,补齐重建链路与搜索测试页 UI` +- 文档定位:基于用户提供的两份 PDF、UI 截图、最新补充确认口径,以及当前 FastGPT 仓库事实,重新定义图搜图功能的产品边界、前后端影响域和实现策略。 + +## 1. 需求背景与目标 + +### 1.1 背景 + +当前 FastGPT 知识库已支持文本向量检索、全文检索、混合检索、VLM 图片解析、图片文本索引和知识库搜索节点,但还没有完整的“图片作为查询输入,检索相似图片/图文分块”的链路。 + +用户提供的资料包括: + +- 产品设计稿:`/Users/xxyyh/Downloads/图搜图产品设计.pdf` +- 团队讨论稿:`/Users/xxyyh/Downloads/图片检索图片的技术方案.pdf` +- 用户补充 UI 图:创建通用知识库弹窗、索引模型下拉、文件分块弹窗、图片分块弹窗、知识库搜索节点、搜索测试页、搜索测试图片上传态;图片自动索引为逻辑矩阵,不作为 UI 图放入。 + +注意:团队讨论稿里已有方案只作为参考,不能机械照搬。本文档以用户最新确认口径为准。 + +### 1.2 最新确认口径 + +| 编号 | 维度 | 最新确认结果 | 设计结论 | +|---|---|---|---| +| C1 | 模型能力字段 | embedding 模型复用 `vision?: boolean` | 老 embedding 模型缺省按 `vision=false` 兼容;`vision=true` 表示该 embedding 模型支持图片向量化 | +| C2 | 模型标签顺序 | `Beta` 放在 `多模态` 前面 | 模型下拉展示顺序为 `模型名`、`Beta`、`多模态` | +| C3 | 工作流检索内容 | 把“检索内容”本身改成 `Array`,同时接“用户问题”和“文件链接” | 不再新增一个单独外露的文件链接字段;后端归一化时从数组中拆文本和图片链接,非图片文件链接过滤掉 | +| C4 | 搜索测试图片来源 | 允许本地上传图片,不支持上传文件,支持仅上传图片不带文字 | 搜索测试页输入框左下角增加图片上传按钮;已上传图片在输入框顶部以缩略图排列,上传中展示独立 loading 卡片;图片检索历史 hover 时展示图片缩略图浮层 | +| C5 | 普通索引 + 无 VLM 搜索逻辑 | 搜索阶段和现在逻辑一样;创建/索引增强阶段控制不可选 | “图片自动索引”在不满足条件时禁用,提示文字根据索引模型和 VLM 配置动态变化 | +| C6 | embedding 模型是否支持图片 | 在模型配置页里由用户手动打开“支持图片识别”开关,默认关闭 | 开关打开后保存 `vision=true`;未打开或老模型无 `vision` 时按 text-only 处理 | +| C7 | 创建知识库提示文案 | 索引模型问号、多模态标签 hover、图片理解模型问号需要使用用户最新指定文案 | 创建弹窗和索引模型下拉必须接入固定 i18n 文案,不能复用旧文案或临时写死 | +| C8 | 搜索测试图片限制 | 最多 10 张;超出提示 `最多支持上传10张图片`;图片大小和格式跟随系统,超出的直接过滤 | 前端选择和后端上传都只接受图片;非图片文件、系统不支持格式、超出大小的图片直接过滤,不进入待搜索列表 | +| C9 | 图文混合检索召回分支 | 召回方式必须按知识库 embedding 是否多模态、是否配置 VLM 分情况处理 | 多模态 embedding 直接走 text/image modality 检索;有 VLM 时额外走图片转文字召回并合并;普通 embedding 有 VLM 时先将查询图片转文字再检索;普通 embedding 无 VLM 时不做图片检索 | +| C10 | 模型字段版本讨论 | 曾讨论过给 embedding 加 `version` 或新增 `modalities`,最终决定不采用 | 直接复用现有 `vision` 能力字段,减少配置字段扩散 | +| C11 | 索引类型命名 | 现有 `image` 先不改名,新多模态图片向量索引命名为 `imageEmbedding` | `image` 继续表示现有 VLM 图片文本索引,`imageEmbedding` 表示新增图片向量索引 | +| C12 | 搜索测试无图片能力时上传入口 | 普通 embedding 且无 VLM 时,不允许在搜索测试页上传图片 | 图片上传按钮 disabled,hover 提示 `请配置图片理解模型或多模态索引模型`;后端仍兜底兼容绕过前端的 `queryImageUrls` | +| C13 | 搜索测试图片过期 | 本地上传到搜索测试的图片只作临时检索输入,过期时间固定 3 小时 | 上传对象必须写入 TTL/过期记录,`expiredTime = addHours(new Date(), 3)`;搜索历史只保存受控缩略图引用和数量,不保存 base64 或完整私有预签名 URL | + +### 1.3 业务目标 + +- 创建知识库时,用户能直观看到哪些索引模型支持多模态能力。 +- 创建知识库时,用户能通过 `索引模型` 问号提示理解向量索引用途、跨模型查询限制和切换模型需重建全量向量索引的风险。 +- 用户 hover `多模态` 标签时,能看到“多模态索引模型可以给图片生成向量。”的能力解释。 +- 创建知识库时,用户能通过 `图片理解模型` 问号提示理解 VLM 会自动标注文档图片并生成文本描述,辅助文本检索。 +- 知识库导入图片或含图片文档时,可生成“多模态图片索引”,支持以图搜图。 +- 搜索测试页支持本地上传图片,支持纯文本、纯图、图文混合测试;本地上传只支持图片,不支持文件;最多 10 张,超出提示 `最多支持上传10张图片`;搜索测试上传图片只作为临时检索输入,上传对象过期时间固定 3 小时。若当前知识库既没有多模态索引模型也没有图片理解模型,则禁用图片上传按钮并提示 `请配置图片理解模型或多模态索引模型`。 +- 图文混合检索按知识库能力分支召回:多模态 embedding 直接检索图片向量和文本向量;有 VLM 时合并 VLM 文本描述索引;普通 embedding 仅在配置 VLM 时把查询图片转成文字后参与文本检索。 +- 工作流知识库搜索节点的“检索内容”改为 `Array`,可同时接入用户问题和文件链接;文件链接里只有图片链接参与图搜图,PDF、docx、表格等非图片文件链接由后端过滤。 +- 文件分块与图片分块弹窗按新 UI 展示右侧数据索引卡片,图片分块展示图片预览和图片内容。 +- 索引增强里的“图片自动索引”根据商业版、多模态索引模型、VLM 配置动态决定可用性和提示文案。 + +### 1.4 技术目标 + +- 不新增知识库类型。 +- 不新增搜索模式大类。 +- 不改变现有训练状态展示口径。 +- 复用现有向量库、RRF 合并、知识库权限、工作流变量引用体系。 +- 将“VLM 图片文本索引”和“多模态图片向量索引”明确分开,避免重建和检索走错链路。 + +## 2. 当前项目事实基线(基于代码) + +| 能力项 | 现有实现位置(文件路径/符号) | 现状说明 | 结论 | +|---|---|---|---| +| 创建知识库 API | `projects/app/src/pages/api/core/dataset/create.ts` | 接收 `vectorModel/agentModel/vlmModel`,没有多模态能力判断 | 修改 | +| 创建知识库 schema | `packages/global/openapi/core/dataset/api.ts` `CreateDatasetBodySchema` | 已有 `vectorModel/agentModel/vlmModel` | 复用 | +| Dataset schema | `packages/service/core/dataset/schema.ts`、`packages/global/core/dataset/type.ts` | 知识库存 `vectorModel/agentModel/vlmModel/chunkSettings`,不冗余保存模型能力 | 不新增 Dataset 字段,能力从模型配置读取 | +| Embedding 模型 schema | `packages/global/core/ai/model.schema.ts` `EmbeddingModelItemSchema` | 当前无 `vision` 字段;LLM 已使用 `vision` 表示图片能力 | 修改,embedding 复用 `vision?: boolean` | +| 模型读取 | `packages/service/core/ai/model.ts` `getEmbeddingModel` | 可按模型名读取 embedding 配置 | 增加 helper | +| 模型配置页 | `projects/app/src/pageComponents/account/model/ModelConfigTable.tsx`、`projects/app/src/pageComponents/account/model/AddModelBox.tsx` | 已有模型配置列表;LLM 模型已有 `功能配置` 区域和 `支持图片识别` 开关,embedding 模型暂无 `vision` 配置 UI | 修改,embedding 模型设置页新增同风格功能配置 | +| 模型配置 API | `projects/app/src/pages/api/core/ai/model/update.ts`、`projects/app/src/pages/api/core/ai/model/list.ts` | 更新/列表返回模型配置,当前仅 LLM 暴露 `vision` 等能力 | 修改,支持 embedding `vision` 保存和返回 | +| 模型选择器 | `projects/app/src/components/Select/AIModelSelector.tsx` | 只显示 provider icon、模型名、Beta 标签 | 修改,增加多模态标签并调整顺序 | +| 创建知识库弹窗 | `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 已有索引模型、文本理解模型、图片理解模型字段,但布局与新稿不一致 | 修改 | +| 图片上传导入 | `projects/app/src/pageComponents/dataset/detail/Import/diffSource/ImageDataset.tsx` | 支持本地图片导入知识库 | 复用并扩展训练后索引生成 | +| 追加图片数据 | `projects/app/src/pages/api/core/dataset/data/insertImages.ts` | 图片上传后进入 `TrainingModeEnum.imageParse` | 修改,按模型能力补图片向量索引 | +| 图片集合创建 | `projects/app/src/pages/api/core/dataset/collection/create/images.ts` | 当前要求 `dataset.vlmModel`,否则报错 | 需调整:多模态索引模型可无 VLM 生成图片向量索引 | +| 图片解析队列 | `pro/admin/src/service/core/dataset/training/imageParse.ts` | VLM 解析图片为文本后转 chunk | 复用为“普通索引 + 有 VLM”的文本描述索引链路 | +| 图片文本索引队列 | `pro/admin/src/service/core/dataset/training/imageIndex.ts` | 为 Markdown 图片生成 VLM 文本索引,类型偏文本语义 | 与图片向量索引区分 | +| 数据索引类型 | `packages/global/core/dataset/data/constants.ts` `DatasetDataIndexTypeEnum` | 当前 `image` 表示现有 VLM 图片文本索引,虽然命名有歧义但先不改,避免牵扯历史数据 | 保留旧 `image`,新增多模态图片向量索引 `imageEmbedding` | +| 分块弹窗 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 文本和图片分块编辑都在此处,右侧已有 indexes 列表 | 修改成新卡片样式;补充索引内容可见性和删除规则 | +| 数据更新 API | `projects/app/src/pages/api/core/dataset/data/update.ts` | 更新 `q/a/indexes`,文本索引走 `updateData2Dataset` | 图片向量索引需分流 | +| Embedding 服务 | `packages/service/core/ai/embedding/index.ts` `getVectorsByText` | 只支持文本 input | 增加图片 embedding 能力 | +| 向量写入 | `packages/service/common/vectorDB/controller.ts` | 写向量时内部调用文本 embedding | 需要支持预计算向量/图片向量 | +| 搜索测试 schema | `packages/global/openapi/core/dataset/api.ts` `SearchDatasetTestBodySchema` | `text` 必填,无图片字段 | 修改 | +| 搜索测试 API | `projects/app/src/pages/api/core/dataset/searchTest.ts` | 只构造 `queries: [text]` | 修改 | +| 搜索核心 | `packages/service/core/dataset/search/controller.ts` `searchDatasetData` | 只支持文本 queries,向量召回调用 `getVectorsByText` | 修改 | +| 搜索分数 | `packages/global/core/dataset/constants.ts` `SearchScoreTypeEnum` | 无图片向量分数类型 | 推荐新增 `imageEmbedding` | +| RRF 合并 | `packages/global/core/dataset/search/utils.ts` `datasetSearchResultConcat` | 已支持多路列表按权重合并去重 | 复用 | +| 搜索测试页 | `projects/app/src/pageComponents/dataset/detail/Test.tsx` | 当前“语义检索”按钮在输入卡片内,测试按钮也在卡片内,历史项带模式 icon/title | 按新稿大改 | +| 搜索历史 store | `projects/app/src/web/core/dataset/store/searchTest.ts` | 只保存文本和搜索模式 | 修改,支持图片上传摘要与 hover 缩略图引用 | +| 工作流模板 | `packages/global/core/workflow/template/system/datasetSearch.ts` | “检索内容”复用 `Input_Template_UserChatInput`,类型为 string | 改为 `Array` | +| 工作流类型 | `packages/global/core/workflow/runtime/type.ts` | `fileUrlList?: string[]` 已存在,但 dataset search 当前未使用 | 本期不单独外露,检索内容数组内部可含文件链接 | +| 工作流变量兼容 | `packages/global/core/workflow/constants.ts` | `arrayString` 可接收 string 和 arrayString 引用 | 可支撑“用户问题 + 文件链接”同槽位 | +| 文件解析工具 | `packages/service/core/workflow/dispatch/ai/chat.ts` `getInputFiles` | 当前内部用 `parseUrlToFileType` 将链接转文件信息 | 建议抽出公共 helper 给 dataset search 复用 | +| 图片自动索引配置 | `projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx` | 当前只按商业版和 `datasetDetail?.vlmModel` 禁用 | 修改为按商业版、多模态索引、VLM 动态判断 | +| 图片自动索引默认值 | `projects/app/src/pageComponents/dataset/detail/Import/Context.tsx` `defaultFormData.imageIndex` | 默认 `false` | 保持默认不勾选 | +| 图片自动索引配置字段 | `packages/global/core/dataset/type.ts` `ChunkSettingsSchema.imageIndex` | 已存在 `imageIndex` | 复用字段,调整语义和提示 | +| i18n | `packages/web/i18n/zh-CN/dataset.json`、`packages/web/i18n/en/dataset.json`、`packages/web/i18n/zh-Hant/dataset.json` | 已有 `image_auto_parse`、`image_auto_parse_tips` | 需扩展动态文案 | + +## 3. 需求澄清记录 + +| 维度 | 已确认内容 | 待确认内容 | 备注 | +|---|---|---|---| +| 业务目标 | 支持以图搜图、图文混合检索、搜索测试本地上传图片 | 无 | 已确认 | +| 模型能力 | embedding 复用 `vision?: boolean` | 无 | 已确认字段;不再新增 `version` 或 `modalities` | +| 标签顺序 | `Beta` 在 `多模态` 前 | 无 | 已确认 | +| 工作流输入 | “检索内容”本身改为 `Array` | 非图片文件链接过滤策略需实现时按本文档落地 | 已确认方向;兼容旧 string 通过归一化层处理,不把 `string | string[]` 扩散到业务层 | +| 搜索测试图片 | 允许本地上传图片,上传对象 3 小时过期 | 上传接口复用还是新增临时上传接口由实现阶段评估 | 已确认能力;无论复用还是新增,都必须写入 3 小时 TTL | +| 搜索降级 | 搜索阶段保持现有逻辑;创建/索引增强阶段控制不可用 | 无 | 已确认 | +| 图片自动索引 | 非商业版、普通索引无 VLM 时禁用;提示随配置变化 | 无 | 已确认 | +| 文档更新 | 需要更新 OpenAPI、知识库、工作流文档 | 具体文档路径实现时再核对现有目录 | 命中 DocUpdate/DocI18n | + +## 3.1 影响域判定(先判定,再核对规范) + +| 维度 | 是否命中 | 证据(需求/代码锚点) | 核对规范 | 结论 | +|---|---|---|---|---| +| API | Yes | 搜索测试需支持图片,本地上传图片需后端可读 URL,工作流 dispatch 入参类型变化 | `style/api.md` | OpenAPI schema 必须用 zod parse | +| DB | Yes | 需新增/区分图片向量索引类型,或至少扩展索引语义 | `style/db.md` | 尽量不改 Mongo schema,仅扩展 enum/索引语义 | +| Front | Yes | 创建知识库、模型下拉、分块弹窗、搜索测试页、工作流节点、图片自动索引配置均变化 | `style/front.md` | 大量 UI 调整,需 i18n | +| Logger | Yes | 图片向量化、图片读取、模型不支持图片、向量维度不匹配需观测 | `style/logger.md` | 结构化日志且脱敏 | +| Package | Yes | 涉及 `packages/global`、`packages/service`、`packages/web`、`projects/app`、`pro/admin` | `style/package.md` | 类型放 global,服务放 service/app | +| BugFix | No | 新功能,不是线上 bug 修复 | `bug-fix-workflow.md` | N/A | +| DocUpdate | Yes | OpenAPI 与用户使用说明变化 | `doc-update-reminder.md` | 必须列文档更新提醒 | +| DocI18n | Yes | 中文文档若更新需同步英文 | `doc-i18n-standards.md` | 必须中英文同步 | + +## 4. 范围定义 + +### 4.1 In Scope(本期必须) + +1. `EmbeddingModelItemSchema` 增加 `vision?: boolean`,语义为该 embedding 模型是否支持图片向量化。 +2. 模型配置页中 embedding 模型点击设置后,新增 `功能配置` 区域,包含 `支持图片识别` 开关;样式参考 LLM 模型参数设置/功能配置,默认关闭。 +3. embedding 模型 `支持图片识别` 打开后,保存 `vision=true`;关闭时保存/恢复为 `vision=false` 或缺省,按 text-only 模型处理。 +4. 创建知识库索引模型下拉展示 `Beta`、`多模态` 标签,且 `Beta` 在前。 +5. 创建通用知识库弹窗按新稿调整宽度和字段布局,并更新字段提示:`索引模型` 问号、`图片理解模型` 问号和索引模型下拉中的 `多模态` 标签 hover 文案必须使用本文档固定口径。 +6. 索引增强里的“图片自动索引”根据商业版、多模态索引模型、VLM 配置动态启用/禁用和展示提示。 +7. 多模态索引模型导入图片时,即使未配置 VLM,也可生成图片向量索引。 +8. 多模态索引模型 + VLM 时,同时生成图片向量索引和文本描述索引。 +9. 普通索引模型 + VLM 时,沿用 VLM 自动标注图片并生成文本描述索引。 +10. 普通索引模型 + 无 VLM 时,“图片自动索引”禁用,提示配置图片理解模型或切换多模态向量模型。 +11. 文件分块点击弹窗按第三张图改样式:左侧内容,右侧数据索引卡片。 +12. 图片分块点击弹窗按第四张图改样式:左侧图片预览 + 图片内容,右侧数据索引卡片。 +13. 右侧数据索引卡片展示“多模态图片索引”卡片时,索引内容不可见,仅展示 UI 默认说明文案:`已通过多模态模型生成图片向量,支持以图搜图`。 +14. 数据索引删除规则:`默认索引` 不可删除;除默认索引外,其他索引都可以删除,包括多模态图片索引、推测问题索引、摘要索引、自定义索引等。 +15. 知识库 embedding/索引模型切换时,必须按切换后的 `vectorModel + vlmModel` 组合重新决定索引生成策略:多模态模型走图片向量索引能力,普通模型只走 VLM 文本描述索引能力。 +16. embedding/索引模型或 VLM 模型切换后,知识库必须进入全库重建流程或明确待重建状态,所有索引按切换后的模型组合重新生成;不能继续混用旧模型生成的向量。 +17. 搜索测试页按最新上传图片 UI 改版:`搜索配置`、测试按钮下移、历史标题和历史项去掉前置模式 icon/title。 +18. 搜索测试支持本地上传图片并参与搜索;输入框左下角放图片上传按钮,顶部横向展示图片缩略图、删除按钮和上传中 loading 卡片。 +19. 搜索测试图片上传只支持图片,不支持文件;支持仅上传图片、不输入文字直接测试;最多支持 10 张图片,超过时提示 `最多支持上传10张图片`。 +20. 搜索测试图片大小和格式限制跟随系统现有上传规则;不符合系统规则的图片、非图片文件直接过滤,不进入待上传/待搜索列表。 +21. 搜索测试图片上传对象必须设置 3 小时过期时间,建议复用已有 S3 TTL 机制,写入 `expiredTime = addHours(new Date(), 3)`;不能复用正式图片集导入的长过期策略。 +22. 若当前知识库 `!isImageEmbeddingModel(dataset.vectorModel) && !dataset.vlmModel`,搜索测试图片上传按钮 disabled,hover 提示 `请配置图片理解模型或多模态索引模型`;`SearchDatasetTestBodySchema` 仍保留 `queryImageUrls` 兼容能力,后端搜索核心兜底处理绕过前端的请求。 +23. 搜索测试历史如果包含图片检索记录,列表中用 `[图片]` token 表示图片输入,鼠标 hover 到该历史项时弹出图片缩略图浮层。 +24. 工作流知识库搜索节点的“检索内容”改为 `Array`,可同时引用用户问题和文件链接。 +25. 后端统一归一化检索输入:从 `Array` 中拆出文本内容和图片文件链接;文件链接中只有 `ChatFileTypeEnum.image` 进入 `queryImageUrls`,非图片文件链接进入 filtered 统计并被忽略。 +26. 搜索核心支持纯文本、纯图、图文混合、多图并行召回,并复用现有 RRF 合并排序。 +27. 更新 OpenAPI、文档、i18n、测试。 + +### 4.2 Out of Scope(本期不做) + +1. 不新增知识库类型。 +2. 不新增搜索模式大类。 +3. 不重做模型配置页整体框架,仅在 embedding 模型设置表单中新增图片能力开关。 +4. 不做存量知识库自动迁移补图片向量。 +5. 不重构所有工作流文件变量体系。 +6. 不把图片 base64 写入搜索历史、日志或持久化 query。 +7. 不做向量库多维度动态建表。图片 embedding 的向量维度与现有向量写入链路保持一致,维度不匹配沿用现有校验/报错路径处理。 + +## 5. 方案对比 + +| 方案 | 核心思路 | 优点 | 风险 | 实施成本 | 结论 | +|---|---|---|---|---|---| +| 方案A:只做 VLM 图转文检索 | 查询图片先 VLM caption,再走现有文本检索 | 改动较小 | 不是真正图搜图,且多模态索引模型能力无法体现 | 中 | 不采用为主方案 | +| 方案C:工作流新增单独文件链接输入 | 保留 `userChatInput: string`,额外展示 `fileUrlList: string[]` | 兼容性最好 | 不符合用户已确认“检索内容本身改 Array” | 中 | 不采用 | +| 方案D:检索内容改 `Array` | 一个“检索内容”输入同时接用户问题和文件链接,后端做输入归一化 | 符合最新 UI 和用户确认 | 需要兼容旧节点 string 值,后端要区分文本和文件链接 | 中 | 推荐 | + +推荐组合:方案B + 方案D。 + +选型原则: + +- 同等可行时优先最少新增字段、最少新增 UI、复用现有训练和搜索能力。 +- 对用户已确认口径不再另起方案绕开,否则设计就是自嗨,开发还得返工。 + +## 6. 推荐方案详细设计 + +### 6.1 API 设计 + +| 路由/入口 | 方法 | 鉴权 | 请求 | 响应 | 错误分支 | 相关文件 | +|---|---|---|---|---|---|---| +| `/api/core/dataset/searchTest` | POST | `authDataset` Read | `text?: string`、`queryImageUrls?: string[]`、原搜索参数 | 复用现有 `SearchDatasetTestResponseSchema`,可扩展返回图片数量/归一化参数 | 文本和图片同时为空、图片超过 10 张、上传图片读取失败、模型不支持图片向量 | `packages/global/openapi/core/dataset/api.ts`、`projects/app/src/pages/api/core/dataset/searchTest.ts` | +| 搜索测试图片上传入口 | POST | 团队/知识库读权限或临时上传权限 | `multipart/form-data` 图片文件,不支持普通文件 | 可被搜索测试读取的图片 URL/S3 key/文件信息,上传对象 3 小时过期 | 非图片文件直接过滤;图片格式/大小跟随系统限制,超出直接过滤;上传失败;未写入 TTL | 可复用现有文件上传能力或新增临时接口 | +| 工作流 dataset search dispatch | internal | 工作流运行权限 | `userChatInput?: string \| string[]`,其中数组可含文本和文件链接 | `quoteQA`、`nodeResponse`、`toolResponses`,可附带过滤统计 | 检索内容为空、图片文件解析失败、非图片文件链接被过滤 | `packages/service/core/workflow/dispatch/dataset/search.ts` | + +搜索测试请求示例: + +```json +{ + "datasetId": "68ad85a7463006c963799a05", + "text": "找一下类似这张图的内容", + "queryImageUrls": ["dataset/tmp/search-test/xxx.png"], + "limit": 5000, + "similarity": 0.4, + "searchMode": "mixedRecall", + "usingReRank": false +} +``` + +工作流节点运行时归一化前示例: + +```json +{ + "userChatInput": [ + "用户想找红色花朵相关图片", + "https://example.com/files/query-flower.png" + ] +} +``` + +工作流节点运行时归一化后示例: + +```json +{ + "textQueries": ["用户想找红色花朵相关图片"], + "queryImageUrls": ["https://example.com/files/query-flower.png"] +} +``` + +### 6.2 数据设计 + +| 实体/集合 | 字段 | 类型 | 必填 | 默认值 | 索引/约束 | 兼容策略 | +|---|---|---|---|---|---|---| +| `EmbeddingModelItemSchema` | `vision` | boolean | 否 | 缺省按 `false` 判断 | 无 | 老模型无需迁移;仅 embedding 场景语义为“支持图片向量化” | +| `DatasetDataIndexTypeEnum` | `imageEmbedding` | enum | 是,新增枚举 | N/A | 复用 `indexes.dataId` | 与现有 `image` 文本索引分开 | +| `MongoDatasetData.indexes[].text` | 图片索引引用 | string | 是 | N/A | 现有 schema | 图片索引存受控图片引用,不存 base64 | +| 向量库 | `vector` | number[] | 是 | N/A | 现有向量索引 | 多模态模型维度必须符合当前向量库约束 | +| `ChunkSettingsSchema.imageIndex` | 图片自动索引开关 | boolean | 否 | `false` | 现有字段 | 复用字段,不新增开关 | + +### 6.3 图片自动索引可用性矩阵 + +| 场景 | 提示文案 | 按钮/复选框状态 | 设计结论 | +|---|---|---|---| +| 非商业版 | `请升级商业版后使用该功能` | 禁用,复选框灰置 | 沿用现有商业版限制 | +| 多模态索引 + 有 VLM | `为文档中的图片生成图片向量索引和文本描述索引,支持以图搜图` | 可用 | 同时走图片向量索引和 VLM 文本描述索引 | +| 多模态索引 + 无 VLM | `使用多模态模型为图片生成向量索引,支持以图搜图` | 可用 | 只生成图片向量索引 | +| 普通索引 + 有 VLM | `调用 VLM 自动标注文档里的图片,并生成文本描述索引` | 可用 | 保持现有图片自动索引逻辑 | +| 普通索引 + 无 VLM | `需配置图片理解模型,或切换多模态向量模型后,方可启用` | 禁用,复选框灰置 | 不允许勾选 | + +实现注意: + +- 这个矩阵作用在创建/导入/索引增强配置阶段,不改变搜索阶段“和现在的逻辑一样”的口径。 +- 如果禁用时当前表单值为 `true`,前端需要自动置回 `false`,避免灰掉但提交 `true`,这种坑线上最爱冒烟。 +- 多模态能力来自模型配置页 embedding 模型的“支持图片识别”开关,而不是模型名或模型供应商写死判断。开关默认关闭,只有用户主动打开后才把 `vision=true` 写入 embedding 模型配置。 +- 当知识库 `vectorModel` 或 `vlmModel` 发生切换,整个知识库必须按新的模型组合重新生成索引,不能只局部清理 `imageIndex` 或继续沿用旧向量。 +- 切换后的重建策略由 `vectorModel.vision` 和 `vlmModel` 共同决定:多模态 embedding + VLM 生成 `imageEmbedding` 与 VLM 文本索引;多模态 embedding + 无 VLM 只生成 `imageEmbedding`;普通 embedding + VLM 只生成 VLM 文本索引;普通 embedding + 无 VLM 不生成图片相关索引。 +- 存量数据不会因为模型字段变化自动拥有新索引;必须通过全库重建把旧索引替换成切换后模型组合对应的新索引。 + +### 6.4 核心代码设计 + +| 模块 | 关键函数/类型 | 变更说明 | 上下游影响 | +|---|---|---|---| +| 模型能力 | `EmbeddingModelItemSchema`、`isImageEmbeddingModel` | 增加/复用 `vision?: boolean`,缺省 text-only | 前后端统一判断多模态 | +| 模型配置页 | `AddModelBox`、`ModelConfigTable`、`model/update`、`model/list` | embedding 模型新增 `支持图片识别` 开关,默认关闭,打开才写入 `vision=true` | 多模态标签、图片自动索引矩阵、训练分流的能力来源 | +| 模型下拉 | `AIModelSelector` | 展示 `Beta`、`多模态` 标签,`Beta` 前置;`多模态` 标签 hover 展示固定说明 | 创建知识库和其他使用处需避免误伤 | +| 图片自动索引 | `CollectionChunkForm.tsx` | 按矩阵计算 disabled、tooltip、tips | 导入、重训、网站配置等共用表单都受益 | +| 图片 embedding | `getVectorsByImage` | 图片 URL/S3 key 转模型输入,生成图片向量 | 训练和搜索共用 | +| 向量写入 | `insertDatasetDataVector` 或新增底层 helper | 支持外部传入预计算 vectors | 避免图片引用被文本 embedding | +| 图片训练 | `insertImages.ts`、`imageParse.ts`、`imageIndex.ts` | 按多模态/VLM 配置生成图片向量索引和/或文本描述索引 | 新数据可图搜图 | +| 重建索引 | `generateVector.ts` | 模型切换后全库重建;`imageEmbedding` 走图片 embedding,其他索引走文本 embedding | 重建不破坏图片索引,也不混用旧模型向量 | +| 索引卡片删除 | `InputDataModal.tsx`、`data/update.ts`、`data/controller.ts` | 默认索引保护;非默认索引允许删除;多模态图片索引内容隐藏 | UI 和后端状态一致,避免假删除 | +| 搜索核心 | `searchDatasetData` | 增加 `imageQueries/queryImageUrls`,图片向量并行召回 | 搜索测试和工作流复用 | +| 工作流输入 | `datasetSearch.ts` | “检索内容”从 string 改 `Array` | 前端变量引用可同时接用户问题和文件链接 | +| 工作流归一化 | `dispatch/dataset/search.ts` | 通过兼容层读取旧 string 或新 arrayString,并统一产出 `textQueries + queryImageUrls` | 兼容旧节点,业务层不扩散 `string | string[]` | +| 搜索测试 UI | `Test.tsx` | 输入框内图片上传按钮、顶部缩略图/删除/上传中卡片、搜索配置按钮、历史图片 hover 缩略图浮层 | 前端主要改动 | + +### 6.5 技术实现流程图(必填) + +```mermaid +flowchart TD + A["模型 schema
EmbeddingModelItem.vision"] --> B["模型配置页
支持图片识别开关"] + B --> C["创建知识库
索引模型下拉显示 Beta + 多模态"] + C --> D["索引增强配置
按商业版/多模态/VLM 决定图片自动索引可用性"] + D --> E["导入图片或含图片文档"] + E --> F["多模态图片索引
getVectorsByImage 写向量库"] + E --> G["VLM 文本描述索引
有 VLM 时生成文本索引"] + F --> H["搜索入口
搜索测试 / 工作流知识库搜索"] + G --> H + H --> I["输入归一化
文本 + 图片 URL"] + I --> J["文本召回
文本向量/全文检索"] + I --> K["图片召回
每张图单独生成 query vector"] + J --> L["RRF 合并排序"] + K --> L + L --> M["返回引用结果
测试参数/测试结果/quoteQA"] +``` + +实现说明: + +- embedding 模型图片能力复用 `vision` 字段,不再新增 `modalities` 或 `multiModal` 字段。 +- `vision=true` 在 LLM 场景表示图片理解能力,在 embedding 场景表示图片向量化能力;必须通过 `isImageEmbeddingModel` 这类 helper 隔离语义,别在业务里到处裸读字段。 +- `图片自动索引` 用现有 `imageIndex` 字段承载,但提示和启用条件要按矩阵变化。 +- `工作流检索内容` 是一个 `Array` 槽位,后端不能偷懒当纯文本 join 完事,否则图片链接会被当普通文本,图搜图直接歇菜。 +- 工作流文件链接过滤只在后端兼容层做最终判断:复用 `packages/service/core/workflow/utils/context.ts` 的 `parseUrlToFileType`,只有解析为 `ChatFileTypeEnum.image` 的链接进入 `queryImageUrls`;解析为 `ChatFileTypeEnum.file` 的 PDF、docx、xlsx 等链接过滤掉,不进入文本检索。 +- `多模态图片索引` 和 `VLM 文本描述索引` 可以同时存在,但索引类型必须区分。 + +### 6.6 前端设计 + +| 页面/组件 | 入口文件 | 交互状态 | i18n key | 变更说明 | +|---|---|---|---|---| +| 模型配置页 | `projects/app/src/pageComponents/account/model/AddModelBox.tsx`、`ModelConfigTable.tsx` | 初始值、保存中、保存失败、开关打开/关闭 | `account:model.vision`、建议新增 `account:model.embedding_vision_tip`、`common:core.ai.model.multimodal` | embedding 模型设置表单新增 `功能配置/支持图片识别`;打开才保存 `vision=true` | +| 创建知识库弹窗 | `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 加载模型、无模型、创建中、创建失败、QuestionTip hover | 建议新增/替换 `common:core.dataset.embedding_model_tip`、`account_model:vlm_model_tip` 或对应 dataset namespace key | 宽弹窗;名称、索引模型、文本理解模型、图片理解模型按新稿布局;索引模型和图片理解模型问号提示使用固定文案 | +| 索引模型下拉 | `projects/app/src/components/Select/AIModelSelector.tsx` | 长名称省略、标签展示、禁用模型、`多模态` 标签 hover | `common:core.ai.model.beta`、`common:core.ai.model.multimodal`、建议新增 `common:core.ai.model.multimodal_tip` | 标签顺序 `Beta` 在 `多模态` 前;hover `多模态` 标签展示固定说明 | +| 图片自动索引 | `projects/app/src/pageComponents/dataset/detail/Form/CollectionChunkForm.tsx` | 可用、禁用、tooltip、动态 tips | `dataset:image_auto_parse_*` | 按矩阵改禁用条件和提示 | +| 文件分块弹窗 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 加载、编辑、保存中、索引展开/折叠 | `dataset:data_index`、新增多模态图片索引 key | 左内容右索引卡片 | +| 图片分块弹窗 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx` | 图片加载失败、编辑、保存中 | 新增图片内容/多模态图片索引 key | 左图片预览 + 图片内容,右索引卡片 | +| 数据索引卡片 | `projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx`、`projects/app/src/pages/api/core/dataset/data/update.ts` | 默认索引保护、非默认索引删除、多模态图片索引内容隐藏 | 新增多模态图片索引默认说明和删除确认文案 | 默认索引不展示删除入口;其他索引展示删除入口,删除需同步后端索引数据 | +| 工作流知识库搜索节点 | `packages/global/core/workflow/template/system/datasetSearch.ts` | 多变量引用、空值、旧值兼容、非图片文件链接过滤 | `workflow:content_to_search` | 检索内容 valueType 改 `arrayString`;文件链接只有图片参与检索 | +| 搜索测试页 | `projects/app/src/pageComponents/dataset/detail/Test.tsx` | 空、图片上传中、测试中、失败、历史选中、历史 hover | 新增图片上传按钮、缩略图、上传中卡片、搜索配置、测试内容文案、历史图片缩略图浮层 | 按最新搜索测试上传图片图改造 | + +#### 6.6.1 创建知识库模型提示文案 + +该组文案是用户最新明确指定的产品文案,开发时必须接 i18n,不允许沿用旧文案或在组件内写死。 + +| 位置 | 触发方式 | 固定文案 | 实现说明 | +|---|---|---|---| +| `索引模型` label 后的问号 | hover/click QuestionTip | `索引模型可以将知识库内容转成向量,用于进行语义检索。注意,不同索引模型的知识库无法同时查询,切换索引模型需重建全量向量索引,请慎重选择。` | 替换旧 `索引模型可以将自然语言转成向量...选择完索引模型后将无法修改` 口径 | +| 索引模型下拉中的 `多模态` 标签 | hover `多模态` tag | `多模态索引模型可以给图片生成向量。` | hover 只绑定在 `多模态` 标签上;`Beta` 标签不展示该说明 | +| `图片理解模型` label 后的问号 | hover/click QuestionTip | `自动标注文档里的图片并生成文本描述,辅助文本检索` | 替换旧 “对文档中的图片进行额外的索引生成” 一类泛化文案 | + +建议 i18n 落点: + +- `packages/web/i18n/*/common.json`:更新 `core.dataset.embedding model tip` 或新增语义更明确的 `core.dataset.embedding_model_tip`。 +- `packages/web/i18n/*/common.json`:新增 `core.ai.model.multimodal_tip`,用于模型下拉 `多模态` 标签 hover。 +- `packages/web/i18n/*/account_model.json` 或 dataset 相关 namespace:更新/新增 `vlm_model_tip`,用于创建知识库 `图片理解模型` 问号提示。 + +#### 6.6.2 搜索测试图片上传约束 + +| 约束 | 规则 | 前端表现 | 后端/API 要求 | +|---|---|---|---| +| 上传类型 | 只支持图片,不支持文件 | 图片按钮打开系统图片选择;拖拽/粘贴/选择到非图片文件时直接过滤 | 上传接口只接收图片,非图片文件不入库、不返回 URL | +| 纯图片搜索 | 支持仅上传图片,不输入文字 | `text` 为空但存在图片时,`测试` 按钮可用 | `SearchDatasetTestBodySchema` 允许 `text` 为空,只要求 `text` 或 `queryImageUrls` 至少一个存在 | +| 无图片搜索能力 | 普通 embedding 且无 VLM 时不允许上传图片 | 图片上传按钮 disabled;hover 提示 `请配置图片理解模型或多模态索引模型` | schema 仍允许 `queryImageUrls`;搜索核心对绕过前端的纯图片请求返回空召回,图文混合只用文本 | +| 图片数量 | 最多 10 张 | 超过 10 张时提示 `最多支持上传10张图片`,超出的图片不加入列表 | `queryImageUrls` 服务端校验 `max(10)`,防止绕过前端 | +| 图片格式 | 跟随系统图片上传规则 | 系统不支持的格式直接过滤,不展示上传中卡片 | 复用系统现有图片 MIME/后缀白名单 | +| 图片大小 | 跟随系统图片上传规则 | 超出系统大小限制的图片直接过滤,不展示上传中卡片 | 复用系统现有大小限制,不为搜索测试单独开新阈值 | +| 图片过期 | 上传对象 3 小时后自动清理 | 前端不展示过期配置项;历史只保存受控缩略图引用和图片数量 | 上传时写入 `expiredTime = addHours(new Date(), 3)`,清理机制沿用 S3 TTL | + +说明: + +- “直接过滤”表示该文件不会进入待搜索图片列表,也不会生成 `queryImageUrls`。若本批次同时有合法图片,合法图片仍正常加入。 +- 只有数量超过 10 张需要明确弹出 `最多支持上传10张图片`;格式和大小超限沿用系统已有过滤/提示行为,不在本需求里新增另一套提示文案。 +- 无图片搜索能力时禁用上传入口属于前端体验优化,不改变 API schema 的兼容性;后端仍按搜索核心策略兜底,避免旧客户端或绕过前端的请求直接报错。 + +### 6.7 后端搜索策略 + +| 知识库配置 | 输入形态 | 召回分支 | VLM 查询图片转文字 | 结果合并 | +|---|---|---|---|---| +| 多模态 embedding,无 VLM | 纯文本 | 文本 query 使用同一个多模态 embedding 的 text modality,走现有文本向量/全文/混合检索 | 否 | 单路文本结果 | +| 多模态 embedding,无 VLM | 纯图片 | 图片 query 使用 image modality 生成 query vector,检索 `imageEmbedding` 图片向量索引 | 否 | 多图时按图片召回结果 RRF 合并 | +| 多模态 embedding,无 VLM | 图片 + 文字 | 文本分支 + 图片向量分支并行召回 | 否 | 文本结果和图片结果 RRF 合并 | +| 多模态 embedding,有 VLM | 纯文本 | 文本分支检索默认文本索引、VLM 文本描述索引等文本类索引 | 否 | 单路或多文本索引结果合并 | +| 多模态 embedding,有 VLM | 纯图片 | 图片向量分支检索 `imageEmbedding`;同时可用 VLM 将查询图片转成文本,检索 VLM 文本描述索引 | 是 | 图片向量结果 + VLM 文本结果 RRF 合并;同一数据多路命中时权重自然变大 | +| 多模态 embedding,有 VLM | 图片 + 文字 | 用户文字分支 + 图片向量分支 + 查询图片 VLM 文本分支 | 是 | 多路 RRF 合并;同时命中图片索引和 VLM 文本索引的结果排序更靠前 | +| 普通 embedding,有 VLM | 纯文本 | 沿用现有文本向量/全文/混合检索,可命中 VLM 文本描述索引 | 否 | 单路文本结果 | +| 普通 embedding,有 VLM | 纯图片 | 不能直接图片 embedding;先用 VLM 将查询图片转文字,再用普通 embedding 做文本检索 | 是 | VLM caption 文本结果合并;多图可多 caption RRF | +| 普通 embedding,有 VLM | 图片 + 文字 | 用户文字分支 + 查询图片 VLM 文本分支 | 是 | 多路文本结果 RRF 合并 | +| 普通 embedding,无 VLM | 纯文本 | 和现在一致 | 否 | 和现在一致 | +| 普通 embedding,无 VLM | 纯图片 | 不做图片检索 | 否 | 返回空召回结果,不额外报错 | +| 普通 embedding,无 VLM | 图片 + 文字 | 图片被忽略,文字按现有文本检索 | 否 | 返回文本检索结果 | + +说明: + +- 多模态 embedding 的“直接检索”指使用同一个 embedding 模型的不同 modality:文本走 text modality,图片走 image modality。 +- 有 VLM 时,入库图片可能同时存在 `imageEmbedding` 图片向量索引和 VLM 文本描述索引;查询图片也需要额外经 VLM 转文字后,才有机会命中 VLM 文本描述索引。 +- RRF 合并按多路召回列表进行;同一数据同时被图片向量索引和 VLM 文本索引命中,排序权重应自然提升。 +- 普通 embedding 无 VLM 时不做图片检索。纯图片输入返回空召回结果;图文混合输入只使用文字部分,搜索阶段不额外报错。 +- 创建/索引增强阶段仍必须按矩阵禁用非法“图片自动索引”,避免用户以为能生成图片索引。 + +### 6.8 日志与观测设计 + +| 场景 | 日志级别 | category | 结构化字段 | 脱敏策略 | +|---|---|---|---|---| +| 图片 embedding 失败 | error | `LogCategories.MODULE.DATASET.EMBEDDING` | `teamId/datasetId/collectionId/dataId/model/indexType/error` | 不记录 base64、完整 URL、向量数组 | +| 图片上传失败 | warn/error | dataset upload category | `teamId/datasetId/fileCount/mimeType/size/error` | 文件名可脱敏,URL 不落日志 | +| 工作流检索内容归一化失败/文件过滤 | warn/info | dataset search category | `teamId/datasetIds/inputCount/imageCount/textCount/filteredFileCount/error` | 不记录完整用户问题和完整文件 URL | +| 模型不支持图片却尝试生成图片向量 | warn | dataset training category | `teamId/datasetId/model/vision` | 不记录图片内容 | +| 向量维度不匹配 | error | vector category | `model/vectorLength/expectedLength/datasetId` | 不记录向量值 | + +### 6.9 文档 i18n 设计(命中时必填) + +| 中文文件 | 英文文件 | 类型(内容/导航) | 处理动作(新增/更新) | 翻译注意项 | +|---|---|---|---|---| +| `packages/web/i18n/zh-CN/common.json` | `packages/web/i18n/en/common.json` | UI 文案 | 更新/新增 | 更新索引模型 QuestionTip;新增 `多模态` hover tip | +| `packages/web/i18n/zh-CN/account_model.json` 或 dataset namespace | `packages/web/i18n/en/account_model.json` 或对应 namespace | UI 文案 | 更新/新增 | 更新图片理解模型 QuestionTip | +| `document/content/openapi/dataset.mdx` | `document/content/openapi/dataset.en.mdx` | 内容 | 更新 | 同步 `queryImageUrls`、本地上传说明、纯图片搜索、最多 10 张限制 | +| 知识库功能文档目录,具体路径实现时核对 | 对应 `.en.mdx` | 内容 | 更新或新增 | `图搜图` 建议译为 `image-to-image search` | +| 工作流知识库搜索节点文档,具体路径实现时核对 | 对应 `.en.mdx` | 内容 | 更新 | 说明“检索内容”为 `Array`,可接文本和文件链接 | + +补充要求: + +- 若新增中文文档,必须同步英文文档和导航文件。 +- 保持代码块字段名不翻译,例如 `vision`、`queryImageUrls`、`imageEmbedding`。 + +### 6.10 文档更新提醒(必填) + +| 文档路径 | 文档类型 | 更新原因 | 计划更新内容 | 负责人 | 截止时间 | 状态 | +|---|---|---|---|---|---|---| +| `document/content/openapi/dataset.mdx` | OpenAPI 中文文档 | 搜索测试 API 支持图片输入 | 增加本地上传、`queryImageUrls`、纯图片搜索、最多 10 张、图文混合示例 | 开发实现者 | 实现完成前 | 已更新 | +| `document/content/openapi/dataset.en.mdx` | OpenAPI 英文文档 | 中文同步 | 同步英文参数说明与示例,包含纯图片搜索和 10 张限制 | 开发实现者 | 实现完成前 | 已更新 | +| `document/content/introduction/guide/knowledge_base/dataset_engine.mdx` | 功能文档 | 新增图搜图能力 | 说明多模态索引、图片自动索引配置、限制 | 开发实现者 | 实现完成前 | 已更新 | +| `document/content/introduction/guide/knowledge_base/dataset_engine.en.mdx` | 功能文档 | 中文同步 | 同步 image-to-image search 说明 | 开发实现者 | 实现完成前 | 已更新 | +| `document/content/introduction/guide/dashboard/workflow/dataset_search.mdx` | 功能文档 | 检索内容改为 `Array` | 说明同时接用户问题和文件链接 | 开发实现者 | 实现完成前 | 已更新 | +| `document/content/introduction/guide/dashboard/workflow/dataset_search.en.mdx` | 功能文档 | 中文同步 | 同步说明 `Array`、图片链接参与检索、非图片文件过滤 | 开发实现者 | 实现完成前 | 已更新 | + +## 7. 风险、迁移与回滚 + +### 7.1 风险清单 + +1. 工作流兼容风险:现有 dataset search 节点的 `userChatInput` 是 string,新版是 `Array`,需要兼容旧运行数据和旧模板。 +2. 图片链接误判风险:`Array` 同时含文本和文件链接,后端必须用可靠的文件解析逻辑区分;不能简单按 URL 字符串粗暴判断,也不能把 PDF、docx 等非图片文件链接当文本 query。 +3. 索引类型混淆风险:现有 `image` 更偏 VLM 图片文本索引,必须和 `imageEmbedding` 分开。 +4. 向量维度风险:图片 embedding 维度必须和现有向量写入链路保持一致;若模型输出维度不匹配,沿用现有校验/报错路径处理,不在本期引入多维度向量库策略。 +5. 搜索成本风险:多图查询会多次图片 embedding,并行召回会增加模型调用和向量检索成本。 +6. 隐私风险:本地上传图片、私有 S3 URL、预签名 URL 不能进入日志和长期历史明文;搜索测试上传图片必须 3 小时过期,避免测试图长期留存。 +7. UI 误导风险:普通索引 + 无 VLM 时如果“图片自动索引”不禁用,用户会以为图片能被索引,最后搜不到就是纯纯给自己挖坑。 + +### 7.2 迁移策略 + +- 老 embedding 模型缺省无 `vision`,运行时按 `vision=false` 处理,无需迁移。 +- 旧工作流 `userChatInput: string` 由兼容层归一化为单元素文本数组,搜索业务层只接收规范化后的 `textQueries/queryImageUrls`。 +- 新工作流模板将“检索内容”展示为 `Array`,默认只引用用户问题;允许用户按需手动追加文件链接。 +- 存量知识库不自动补图片向量索引;当 `vectorModel` 或 `vlmModel` 切换时,必须进入全库重建流程或明确待重建状态。 +- 旧搜索历史无图片字段时按纯文本历史展示。 +- 新搜索历史若含图片,只保存可用于缩略图展示的受控图片引用、图片数量和摘要,不保存 base64 或完整私有预签名 URL。 + +### 7.3 回滚策略 + +1. 前端隐藏搜索测试图片上传入口和多模态图片索引展示。 +2. 工作流模板回滚为 string 前,保留后端输入兼容层,避免旧流程直接崩。 +3. 停止生成新的 `imageEmbedding` 索引,保留已生成索引不影响文本检索。 +4. 搜索核心关闭图片 query 分支,纯文本链路保持现有行为。 +5. 配置层关闭 embedding 模型 `vision` 后,前端自动回到普通模型表现。 + +## 8. 验收标准 + +| 验收项 | 验收方式 | 通过标准 | +|---|---|---| +| embedding 模型配置开关 | 手工验证/组件测试/API 测试 | `支持图片识别` 默认关闭;打开后保存 `vision=true`;关闭后保存/恢复为 `vision=false` 或缺省 | +| 创建知识库模型下拉 | 手工验证/组件测试 | 支持多模态的模型展示 `Beta`、`多模态`,且 `Beta` 在前 | +| 创建知识库模型提示 | 手工验证/组件测试/i18n 检查 | `索引模型` 问号、`图片理解模型` 问号、`多模态` 标签 hover 分别展示本文档固定文案;zh-CN/en/zh-Hant key 齐全 | +| 图片自动索引矩阵 | 单元测试/手工验证 | 5 种场景的禁用状态和提示文案符合矩阵 | +| 多模态无 VLM 导入图片 | 集成测试/手工验证 | 可生成图片向量索引,不要求 VLM | +| 多模态有 VLM 导入图片 | 集成测试/手工验证 | 同时生成图片向量索引和文本描述索引 | +| 普通索引无 VLM | 手工验证/组件测试 | 图片自动索引禁用,提示要求配置 VLM 或切换多模态模型 | +| 文件分块弹窗 | 手工验证/组件测试 | 左内容右索引卡片,符合第三张图 | +| 图片分块弹窗 | 手工验证/组件测试 | 左图片预览和图片内容,右索引卡片,符合第四张图 | +| 数据索引可见性与删除 | 手工验证/组件测试 | 多模态图片索引内容不可见,只显示默认说明;默认索引不可删除;除默认索引外其他索引都可以删除 | +| 搜索测试页 | 手工验证/组件测试 | `搜索配置` 按钮、测试按钮位置、输入框内图片上传按钮、历史项无前置 icon/title | +| 搜索测试本地上传图片 | 集成测试/手工验证 | 支持图片能力时,上传图片后在输入框顶部展示缩略图;删除按钮可移除;上传中展示 loading 卡片;图片可作为 queryImageUrls 参与检索;不输入文字仅上传图片也可测试 | +| 搜索测试无图片能力 | 手工验证/组件测试 | 普通 embedding 且无 VLM 时图片上传按钮 disabled;hover 展示 `请配置图片理解模型或多模态索引模型` | +| 搜索测试图片限制 | 组件测试/API 测试/手工验证 | 只能上传图片,不支持文件;最多 10 张,超过提示 `最多支持上传10张图片`;图片格式和大小跟随系统规则,超出的直接过滤 | +| 搜索测试图片历史 | 手工验证/组件测试 | 图片检索历史显示 `[图片]` token;鼠标 hover 历史项时展示对应图片缩略图浮层;移出后浮层消失 | +| 工作流检索内容默认值 | 集成测试/手工验证 | 新建知识库搜索节点时,检索内容默认只引用用户问题,不默认引用文件链接 | +| 工作流检索内容手动多引用 | 集成测试 | 用户手动追加文件链接后,一个 `Array` 输入能同时接用户问题和文件链接 | +| 工作流非图片文件过滤 | 单元测试/集成测试 | `Array` 中的 PDF、docx、xlsx 等文件链接被过滤,图片链接进入 `queryImageUrls`,普通文本进入 `textQueries` | +| 多模态 embedding 纯图搜索 | 集成测试 | 每张图片通过 image modality 生成 query vector 并召回 `imageEmbedding` | +| 多模态 embedding + VLM 搜索 | 集成测试 | 图片向量召回和查询图片 VLM 转文字召回同时参与 RRF;同一数据多路命中时排序提升 | +| 普通 embedding + VLM 图片搜索 | 集成测试 | 查询图片先经 VLM 转文字,再走普通文本检索 | +| 普通 embedding 无 VLM 图片搜索 | 集成测试 | 纯图片返回空召回结果;图文混合时忽略图片,仅使用文字检索 | +| 图文混合搜索 | 集成测试 | 按知识库能力选择文本、图片向量、VLM caption 分支,并进入 RRF 合并 | +| 文档与 i18n | 文档检查 | 中英文文档和 UI 文案同步 | + +## 9. MECE 核查结论 + +### 9.1 相互独立检查结果 + +- 模型能力字段和知识库字段独立:`vision` 属于模型配置,不写入 Dataset。 +- 图片自动索引配置和搜索阶段行为独立:配置阶段负责能否生成索引,搜索阶段保持现有逻辑并使用已存在索引。 +- VLM 文本描述索引和多模态图片向量索引独立:一个图转文,一个图转向量,索引类型不能混。 +- 搜索测试和工作流入口独立:前者本地上传图片,后者从 `Array` 中解析文件链接,但后端搜索核心复用。 +- UI 分块弹窗和训练索引链路独立:弹窗展示索引状态,不应自己承担训练逻辑。 + +### 9.2 完全穷尽检查结果 + +| 链路 | 是否覆盖 | 说明 | +|---|---|---| +| 模型配置 | 是 | embedding `vision` | +| 创建知识库 | 是 | 模型下拉和三模型字段 | +| 索引增强 | 是 | 图片自动索引矩阵 | +| 图片导入 | 是 | 多模态/VLM 组合 | +| 分块展示 | 是 | 文件分块和图片分块弹窗 | +| 搜索测试 | 是 | 本地上传图片、纯图测试、最多 10 张、仅图片不支持文件、搜索配置、历史图片 hover 缩略图浮层 | +| 工作流节点 | 是 | 检索内容 `Array` | +| 搜索核心 | 是 | 纯文本、纯图、图文混合、多图 | +| 计费与日志 | 是 | 图片 embedding、VLM、脱敏日志 | +| 文档/i18n | 是 | OpenAPI、知识库、工作流文档 | + +### 9.3 修订动作与最终边界 + +- 旧文档 `图搜图-接入-*` 不覆盖,保留历史讨论痕迹。 +- 本文档按最新确认口径作为后续实现依据。 +- 后续若需要再改“工作流检索内容是否保留 string 兼容展示”,只能在实现文档中做兼容策略,不能推翻本期“Array”产品口径。 + +## 10. TODO + +- [x] 按本文档同步更新 `图搜图-当前需求-功能开发文档.md`。 +- [x] 开发前确认创建知识库弹窗的三处提示文案对应 i18n key:索引模型问号、多模态 hover、图片理解模型问号。 +- [x] 开发前核对模型配置中实际可用的多模态 embedding 模型。 +- [x] 开发前确认搜索测试本地图片上传复用接口还是新增临时接口,并复用系统图片格式/大小限制;本次采用新增搜索测试临时上传接口,过期时间固定 3 小时。 +- [x] 实现后补齐 OpenAPI、知识库、工作流文档与英文版。 +- [x] 实现后按测试矩阵跑局部测试和最终检查。 +- [x] 恢复 `pro` 子模块后,补齐 `pro/admin/src/service/core/dataset/training/imageParse.ts` 和 `pro/admin/src/service/core/dataset/training/imageIndex.ts` 的图片向量/VLM 文本索引分流实现。 +- [x] 反向核对后补齐模型切换重建链路:切到普通 embedding 且无 VLM 时同步清理 dataset/collection 的 `imageIndex=false`。 +- [x] 反向核对后补齐工作流输入归一化:普通无后缀 URL 不再被误判为非图片文件并过滤。 +- [x] 反向核对后补齐搜索测试页 UI:输入标题、搜索配置按钮、图片缩略图顶部、上传按钮、测试按钮下移、历史标题去 icon。 diff --git a/document/content/guide/build/workflow/nodes/dataset_search.en.mdx b/document/content/guide/build/workflow/nodes/dataset_search.en.mdx index b218a22aed6d..05204454d7ea 100644 --- a/document/content/guide/build/workflow/nodes/dataset_search.en.mdx +++ b/document/content/guide/build/workflow/nodes/dataset_search.en.mdx @@ -21,6 +21,12 @@ For detailed parameters and internal logic, see: [FastGPT Knowledge Base Search] Select one or more Knowledge Bases using the **same embedding model** for vector search. +### Input - Search Content + +Search content is an `Array`. New nodes reference the user's question by default. You can also add file links to the same input for mixed text-image retrieval. + +At runtime, FastGPT separates text from file links automatically: plain text goes into text search, image links go into image search, and non-image file links such as PDF, docx, xlsx, txt, and pptx are filtered out. Filtered file links are not searched as text and do not make the node fail. + ### Input - Search Parameters [View parameter details](../../../dataset/dataset_engine.en.mdx#搜索参数) diff --git a/document/content/guide/build/workflow/nodes/dataset_search.mdx b/document/content/guide/build/workflow/nodes/dataset_search.mdx index 76f7ca9614fd..dd9f6e9f1ed2 100644 --- a/document/content/guide/build/workflow/nodes/dataset_search.mdx +++ b/document/content/guide/build/workflow/nodes/dataset_search.mdx @@ -21,6 +21,12 @@ description: FastGPT AI 知识库搜索模块介绍 可以选择一个或多个**相同向量模型**的知识库,用于向量搜索。 +### 输入 - 检索内容 + +检索内容为`Array`,新建节点默认引用用户问题。你也可以在同一个输入里追加文件链接,用于图文混合检索。 + +运行时会自动拆分文本和文件链接:普通文本进入文本检索;图片链接进入图片检索;PDF、docx、xlsx、txt、pptx 等非图片文件链接会被过滤,不会作为文本参与检索,也不会让节点报错。 + ### 输入 - 搜索参数 [点击查看参数介绍](../../../dataset/dataset_engine.mdx#搜索参数) diff --git a/document/content/guide/dataset/dataset_engine.en.mdx b/document/content/guide/dataset/dataset_engine.en.mdx index 6e0440df6ec2..9367ba9f6767 100644 --- a/document/content/guide/dataset/dataset_engine.en.mdx +++ b/document/content/guide/dataset/dataset_engine.en.mdx @@ -63,6 +63,18 @@ This means you can continuously improve data chunk accuracy through annotation. ![](/imgs/dataset_search_process.png) +### Image and Mixed Text-Image Search + +When `Support Image Recognition` is enabled for an embedding model in model settings, FastGPT treats it as a multimodal embedding model. Image data can generate an `imageEmbedding` vector index for image-to-image search. If the Knowledge Base also has an image understanding model, images can also generate text-description indexes so text queries can match image content. + +Search automatically branches based on the Knowledge Base configuration: + +- Multimodal embedding model: text queries use text vector search, image queries use image vector search, and mixed text-image queries are merged with RRF. +- Multimodal embedding model + image understanding model: in addition to image vector search, query images are captioned and used for text search. +- Text-only embedding model + image understanding model: query images are captioned first, then searched as text. +- Text-only embedding model without an image understanding model: image inputs are ignored; mixed text-image queries use only the text portion. + +The search test page supports local image uploads for image-only and mixed text-image tests, up to 10 images. Images uploaded for search testing are temporary, expire after 3 hours, and are not saved as Knowledge Base data. ## Search Parameters | | | | diff --git a/document/content/guide/dataset/dataset_engine.mdx b/document/content/guide/dataset/dataset_engine.mdx index 7c4a446b7d08..d4a3126c29ad 100644 --- a/document/content/guide/dataset/dataset_engine.mdx +++ b/document/content/guide/dataset/dataset_engine.mdx @@ -63,6 +63,18 @@ FastGPT 采用了`PostgresSQL`的`PG Vector`插件作为向量检索器,索引 ![](/imgs/dataset_search_process.png) +### 图片与图文混合检索 + +当索引模型在模型配置中开启`支持图片识别`后,FastGPT 会把它视为多模态索引模型:图片数据可以生成`imageEmbedding`图片向量索引,用于以图搜图。若知识库同时配置了图片理解模型,图片还会生成文本描述索引,便于通过文字命中图片内容。 + +搜索时会按知识库能力自动分支: + +- 多模态索引模型:文本查询走文本向量检索,图片查询走图片向量检索,图文混合时通过 RRF 合并结果。 +- 多模态索引模型 + 图片理解模型:除图片向量检索外,查询图片还会先转成文本描述,再参与文本检索。 +- 普通索引模型 + 图片理解模型:图片会先转成文本描述,再走普通文本检索。 +- 普通索引模型且无图片理解模型:图片输入不会参与检索;图文混合时仅使用文字部分。 + +搜索测试页支持本地上传图片进行纯图或图文混合测试,最多 10 张。测试上传的图片只用于临时检索,3 小时后过期,不会作为知识库正式数据保存。 ## 搜索参数 | | | | diff --git a/document/content/openapi/dataset.en.mdx b/document/content/openapi/dataset.en.mdx index a9436e68d645..e63739bd190f 100644 --- a/document/content/openapi/dataset.en.mdx +++ b/document/content/openapi/dataset.en.mdx @@ -1248,6 +1248,9 @@ curl --location --request POST 'http://localhost:3000/api/core/dataset/searchTes --data-raw '{ "datasetId": "Dataset ID", "text": "Who is the director", + "queryImageUrls": [ + "temp/teamId/search-image.png" + ], "limit": 5000, "similarity": 0, "searchMode": "embedding", @@ -1265,7 +1268,8 @@ curl --location --request POST 'http://localhost:3000/api/core/dataset/searchTes
- datasetId - Dataset ID -- text - Text to test +- text - Text to test. It can be empty if queryImageUrls contains at least one image. +- queryImageUrls - Temporary image keys for search testing, up to 10 images. Supports image-only search and mixed text-image search. Public image URLs, dataset keys, and chat keys are not supported here. For API calls, first upload the local image through `/api/core/dataset/file/uploadSearchTestImage`, then pass the returned `key` (`temp/${teamId}/...`) in this field. The temporary key expires after 3 hours. - limit - Maximum tokens - similarity - Minimum similarity (0~1, optional) - searchMode - Search mode: embedding | fullTextRecall | mixedRecall @@ -1303,3 +1307,29 @@ Returns top k results. limit is the maximum tokens, up to 20000 tokens. + +### Upload Search Test Image + +Use this endpoint to upload a local image from the search test page or an OpenAPI client. Only image files are supported; PDF, docx, xlsx, txt, pptx, and other document files are not accepted. Image format and size limits follow the system upload configuration. Uploaded objects are only used for temporary retrieval and expire after 3 hours. + +To use image retrieval with `/api/core/dataset/searchTest`, call this endpoint first and pass `data.key` from the response to `queryImageUrls`. Public image URLs are not supported directly in `queryImageUrls`. + +```bash +curl --location --request POST 'http://localhost:3000/api/core/dataset/file/uploadSearchTestImage' \ +--header 'Authorization: Bearer fastgpt-xxxxx' \ +--form 'datasetId="Dataset ID"' \ +--form 'file=@"./query.png"' +``` + +Response example: + +```json +{ + "code": 200, + "statusText": "", + "data": { + "key": "temp/teamId/query.png", + "previewUrl": "https://..." + } +} +``` diff --git a/document/content/openapi/dataset.mdx b/document/content/openapi/dataset.mdx index c3862826c586..29905aae46ca 100644 --- a/document/content/openapi/dataset.mdx +++ b/document/content/openapi/dataset.mdx @@ -1230,6 +1230,9 @@ curl --location --request POST 'http://localhost:3000/api/core/dataset/searchTes --data-raw '{ "datasetId": "知识库的ID", "text": "导演是谁", + "queryImageUrls": [ + "temp/teamId/search-image.png" + ], "limit": 5000, "similarity": 0, "searchMode": "embedding", @@ -1247,7 +1250,8 @@ curl --location --request POST 'http://localhost:3000/api/core/dataset/searchTes
- datasetId - 知识库ID -- text - 需要测试的文本 +- text - 需要测试的文本,可为空;当 text 为空时,queryImageUrls 至少需要传 1 张图片 +- queryImageUrls - 搜索测试图片临时 key,最多 10 张;支持纯图片搜索和图文混合搜索。当前不支持直接传公网 URL、dataset key 或 chat key。API 调用时需要先通过 `/api/core/dataset/file/uploadSearchTestImage` 上传本地图片,使用响应中的 `key`(格式为 `temp/${teamId}/...`)填入该字段。该临时 key 3 小时后过期 - limit - 最大 tokens 数量 - similarity - 最低相关度(0~1,可选) - searchMode - 搜索模式:embedding | fullTextRecall | mixedRecall @@ -1285,3 +1289,29 @@ curl --location --request POST 'http://localhost:3000/api/core/dataset/searchTes + +### 上传搜索测试图片 + +该接口用于搜索测试页或 OpenAPI 调用方上传本地图片。仅支持图片文件,不支持 PDF、docx、xlsx、txt、pptx 等普通文件;图片格式和大小限制跟随系统上传配置。上传对象只用于临时检索,过期时间固定为 3 小时。 + +如果要在 `/api/core/dataset/searchTest` 中使用图片检索,先调用本接口上传图片,然后将响应中的 `data.key` 填入 `queryImageUrls`。当前不支持把公网图片 URL 直接填入 `queryImageUrls`。 + +```bash +curl --location --request POST 'http://localhost:3000/api/core/dataset/file/uploadSearchTestImage' \ +--header 'Authorization: Bearer fastgpt-xxxxx' \ +--form 'datasetId="知识库的ID"' \ +--form 'file=@"./query.png"' +``` + +响应示例: + +```json +{ + "code": 200, + "statusText": "", + "data": { + "key": "temp/teamId/query.png", + "previewUrl": "https://..." + } +} +``` diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 8f0b9424b287..7a034778dc52 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -13,144 +13,144 @@ "content/faq/other.mdx": "2026-04-26T21:08:47+08:00", "content/faq/points_consumption.en.mdx": "2026-04-26T21:08:47+08:00", "content/faq/points_consumption.mdx": "2026-04-26T21:08:47+08:00", - "content/guide/admin/sso.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/admin/sso.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/admin/teamMode.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/admin/teamMode.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/evaluation.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/evaluation.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/general/ai_settings.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/general/ai_settings.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/general/chat_input_guide.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/general/chat_input_guide.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/general/fileInput.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/general/fileInput.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/dingtalk.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/dingtalk.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/feishu.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/feishu.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/mcp_server.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/mcp_server.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/official_account.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/official_account.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/openapi.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/openapi.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/wechat.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/wechat.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/wecom.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/publish/wecom.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/mcp_tools.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/mcp_tools.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/bing_search_plugin.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/bing_search_plugin.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/dev_system_tool.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/dev_system_tool.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/doc2x_plugin_guide.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/doc2x_plugin_guide.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/google_search_plugin_guide.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/google_search_plugin_guide.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/searxng_plugin_guide.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/searxng_plugin_guide.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/upload_system_tool.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/tools/system-plugins/upload_system_tool.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/intro.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/intro.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/ai_chat.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/ai_chat.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/content_extract.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/content_extract.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/coreferenceResolution.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/coreferenceResolution.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/custom_feedback.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/custom_feedback.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/dataset_search.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/dataset_search.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/document_parsing.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/document_parsing.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/form_input.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/form_input.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/http.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/http.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/knowledge_base_search_merge.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/knowledge_base_search_merge.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/laf.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/laf.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/loop.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/loop.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/parallel_run.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/parallel_run.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/question_classify.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/question_classify.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/reply.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/reply.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/sandbox-v2.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/sandbox-v2.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/text_editor.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/text_editor.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/tfswitch.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/tfswitch.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/tool.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/tool.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/user-selection.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/user-selection.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/variable_update.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/build/workflow/nodes/variable_update.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/chat/htmlRendering.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/chat/htmlRendering.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/chat/quoteList.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/chat/quoteList.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/collection_tags.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/collection_tags.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/dataset_engine.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/dataset_engine.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/rag.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/rag.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/template.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/template.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/third-party/api_dataset.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/third-party/api_dataset.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/third-party/dingtalk_dataset.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/third-party/dingtalk_dataset.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/third-party/lark_dataset.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/third-party/lark_dataset.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/third-party/third_dataset.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/third-party/third_dataset.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/third-party/yuque_dataset.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/third-party/yuque_dataset.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/dataset/websync.en.mdx": "2026-05-07T14:57:37+08:00", - "content/guide/dataset/websync.mdx": "2026-05-07T14:57:37+08:00", - "content/guide/getting-started/index.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/getting-started/index.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/getting-started/quick-start.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/getting-started/quick-start.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/index.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/index.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/cloud/faq.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/cloud/faq.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/cloud/intro.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/cloud/intro.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/cloud/privacy.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/cloud/privacy.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/cloud/terms.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/cloud/terms.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/commercial.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/commercial.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/opensource/intro.en.mdx": "2026-05-07T14:57:37+08:00", - "content/guide/version/opensource/intro.mdx": "2026-05-07T14:57:37+08:00", - "content/guide/version/opensource/license.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/version/opensource/license.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/workspace/customDomain.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/workspace/customDomain.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/workspace/team/invitation_link.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/workspace/team/invitation_link.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/workspace/team/team_roles_permissions.en.mdx": "2026-05-07T14:11:45+08:00", - "content/guide/workspace/team/team_roles_permissions.mdx": "2026-05-07T14:11:45+08:00", + "content/guide/admin/sso.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/admin/sso.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/admin/teamMode.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/admin/teamMode.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/evaluation.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/evaluation.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/general/ai_settings.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/general/ai_settings.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/general/chat_input_guide.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/general/chat_input_guide.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/general/fileInput.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/general/fileInput.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/dingtalk.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/dingtalk.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/feishu.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/feishu.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/mcp_server.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/mcp_server.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/official_account.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/official_account.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/openapi.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/openapi.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/wechat.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/wechat.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/wecom.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/publish/wecom.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/mcp_tools.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/mcp_tools.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/bing_search_plugin.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/bing_search_plugin.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/dev_system_tool.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/dev_system_tool.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/doc2x_plugin_guide.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/doc2x_plugin_guide.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/google_search_plugin_guide.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/google_search_plugin_guide.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/searxng_plugin_guide.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/searxng_plugin_guide.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/upload_system_tool.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/tools/system-plugins/upload_system_tool.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/intro.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/intro.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/ai_chat.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/ai_chat.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/content_extract.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/content_extract.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/coreferenceResolution.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/coreferenceResolution.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/custom_feedback.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/custom_feedback.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/dataset_search.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/dataset_search.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/document_parsing.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/document_parsing.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/form_input.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/form_input.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/http.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/http.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/knowledge_base_search_merge.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/knowledge_base_search_merge.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/laf.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/laf.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/loop.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/loop.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/parallel_run.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/parallel_run.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/question_classify.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/question_classify.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/reply.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/reply.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/sandbox-v2.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/sandbox-v2.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/text_editor.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/text_editor.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/tfswitch.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/tfswitch.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/tool.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/tool.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/user-selection.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/user-selection.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/variable_update.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/build/workflow/nodes/variable_update.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/chat/htmlRendering.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/chat/htmlRendering.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/chat/quoteList.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/chat/quoteList.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/collection_tags.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/collection_tags.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/dataset_engine.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/dataset_engine.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/rag.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/rag.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/template.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/template.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/third-party/api_dataset.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/third-party/api_dataset.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/third-party/dingtalk_dataset.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/third-party/dingtalk_dataset.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/third-party/lark_dataset.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/third-party/lark_dataset.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/third-party/third_dataset.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/third-party/third_dataset.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/third-party/yuque_dataset.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/third-party/yuque_dataset.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/websync.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/dataset/websync.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/getting-started/index.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/getting-started/index.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/getting-started/quick-start.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/getting-started/quick-start.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/index.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/index.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/cloud/faq.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/cloud/faq.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/cloud/intro.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/cloud/intro.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/cloud/privacy.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/cloud/privacy.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/cloud/terms.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/cloud/terms.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/commercial.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/commercial.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/opensource/intro.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/opensource/intro.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/opensource/license.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/version/opensource/license.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/workspace/customDomain.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/workspace/customDomain.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/workspace/team/invitation_link.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/workspace/team/invitation_link.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/workspace/team/team_roles_permissions.en.mdx": "2026-05-07T15:06:40+08:00", + "content/guide/workspace/team/team_roles_permissions.mdx": "2026-05-07T15:06:40+08:00", "content/openapi/app.en.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/app.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/chat.en.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/chat.mdx": "2026-04-26T21:08:47+08:00", - "content/openapi/dataset.en.mdx": "2026-05-07T14:11:45+08:00", - "content/openapi/dataset.mdx": "2026-05-07T14:11:45+08:00", + "content/openapi/dataset.en.mdx": "2026-05-07T15:06:40+08:00", + "content/openapi/dataset.mdx": "2026-05-07T15:06:40+08:00", "content/openapi/index.en.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/index.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/intro.en.mdx": "2026-04-26T21:08:47+08:00", @@ -161,8 +161,8 @@ "content/self-host/config/env.mdx": "2026-05-06T18:25:24+08:00", "content/self-host/config/json.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/config/json.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/config/model/intro.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/config/model/intro.mdx": "2026-05-07T14:11:45+08:00", + "content/self-host/config/model/intro.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/config/model/intro.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/config/model/minimax.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/config/model/minimax.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/config/model/siliconCloud.en.mdx": "2026-04-26T21:08:47+08:00", @@ -187,14 +187,14 @@ "content/self-host/custom-models/ollama.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/custom-models/xinference.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/custom-models/xinference.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/deploy/docker.en.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/deploy/docker.mdx": "2026-04-26T21:08:47+08:00", + "content/self-host/deploy/docker.en.mdx": "2026-05-07T15:08:21+08:00", + "content/self-host/deploy/docker.mdx": "2026-05-07T15:08:21+08:00", "content/self-host/deploy/sealos.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/deploy/sealos.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/design/dataset.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/design/dataset.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/design/design_plugin.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/design/design_plugin.mdx": "2026-05-07T14:11:45+08:00", + "content/self-host/design/design_plugin.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/design/design_plugin.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/dev.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/dev.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/index.en.mdx": "2026-04-26T21:08:47+08:00", @@ -274,8 +274,8 @@ "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/upgrading/outdated/4100.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/4100.mdx": "2026-05-07T14:11:45+08:00", + "content/self-host/upgrading/outdated/4100.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/4100.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/outdated/4101.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4101.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4110.en.mdx": "2026-04-26T21:08:47+08:00", @@ -314,16 +314,16 @@ "content/self-host/upgrading/outdated/462.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/463.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/463.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/upgrading/outdated/464.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/464.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/465.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/465.mdx": "2026-05-07T14:11:45+08:00", + "content/self-host/upgrading/outdated/464.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/464.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/465.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/465.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/outdated/466.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/466.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/467.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/467.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/upgrading/outdated/468.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/468.mdx": "2026-05-07T14:11:45+08:00", + "content/self-host/upgrading/outdated/468.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/468.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/outdated/469.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/469.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/47.en.mdx": "2026-04-26T21:08:47+08:00", @@ -340,14 +340,14 @@ "content/self-host/upgrading/outdated/4811.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4812.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4812.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/upgrading/outdated/4813.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/4813.mdx": "2026-05-07T14:11:45+08:00", + "content/self-host/upgrading/outdated/4813.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/4813.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/outdated/4814.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4814.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/upgrading/outdated/4815.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/4815.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/4816.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/4816.mdx": "2026-05-07T14:11:45+08:00", + "content/self-host/upgrading/outdated/4815.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/4815.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/4816.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/4816.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/outdated/4817.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4817.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4818.en.mdx": "2026-04-26T21:08:47+08:00", @@ -384,16 +384,16 @@ "content/self-host/upgrading/outdated/491.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4910.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4910.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/upgrading/outdated/4911.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/4911.mdx": "2026-05-07T14:11:45+08:00", + "content/self-host/upgrading/outdated/4911.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/4911.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/outdated/4912.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4912.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4913.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4913.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4914.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/4914.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/upgrading/outdated/492.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/492.mdx": "2026-05-07T14:11:45+08:00", + "content/self-host/upgrading/outdated/492.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/492.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/outdated/493.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/493.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/494.en.mdx": "2026-04-26T21:08:47+08:00", @@ -406,12 +406,12 @@ "content/self-host/upgrading/outdated/497.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/498.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/498.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/upgrading/outdated/499.en.mdx": "2026-05-07T14:11:45+08:00", - "content/self-host/upgrading/outdated/499.mdx": "2026-05-07T14:11:45+08:00", + "content/self-host/upgrading/outdated/499.en.mdx": "2026-05-07T15:06:40+08:00", + "content/self-host/upgrading/outdated/499.mdx": "2026-05-07T15:06:40+08:00", "content/self-host/upgrading/upgrade-intruction.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00", - "content/toc.en.mdx": "2026-05-07T14:11:45+08:00", - "content/toc.mdx": "2026-05-07T14:11:45+08:00", + "content/toc.en.mdx": "2026-05-07T15:06:40+08:00", + "content/toc.mdx": "2026-05-07T15:06:40+08:00", "content/use-cases/app-cases/dalle3.en.mdx": "2026-04-26T21:08:47+08:00", "content/use-cases/app-cases/dalle3.mdx": "2026-04-26T21:08:47+08:00", "content/use-cases/app-cases/english_essay_correction_bot.en.mdx": "2026-04-26T21:08:47+08:00", @@ -426,10 +426,10 @@ "content/use-cases/app-cases/lab_appointment.mdx": "2026-04-26T21:08:47+08:00", "content/use-cases/app-cases/multi_turn_translation_bot.en.mdx": "2026-04-26T21:08:47+08:00", "content/use-cases/app-cases/multi_turn_translation_bot.mdx": "2026-04-26T21:08:47+08:00", - "content/use-cases/app-cases/submit_application_template.en.mdx": "2026-05-07T14:11:45+08:00", - "content/use-cases/app-cases/submit_application_template.mdx": "2026-05-07T14:11:45+08:00", + "content/use-cases/app-cases/submit_application_template.en.mdx": "2026-05-07T15:06:40+08:00", + "content/use-cases/app-cases/submit_application_template.mdx": "2026-05-07T15:06:40+08:00", "content/use-cases/app-cases/translate-subtitle-using-gpt.en.mdx": "2026-04-26T21:08:47+08:00", "content/use-cases/app-cases/translate-subtitle-using-gpt.mdx": "2026-04-26T21:08:47+08:00", - "content/use-cases/index.en.mdx": "2026-05-07T14:11:45+08:00", - "content/use-cases/index.mdx": "2026-05-07T14:11:45+08:00" + "content/use-cases/index.en.mdx": "2026-05-07T15:06:40+08:00", + "content/use-cases/index.mdx": "2026-05-07T15:06:40+08:00" } \ No newline at end of file diff --git a/packages/global/core/ai/model.schema.ts b/packages/global/core/ai/model.schema.ts index ea58b0128b89..39d4cdd44ca8 100644 --- a/packages/global/core/ai/model.schema.ts +++ b/packages/global/core/ai/model.schema.ts @@ -100,6 +100,7 @@ export const EmbeddingModelItemSchema = PriceTypeSchema.extend(BaseModelItemSche maxToken: z.number(), // model max token weight: z.number(), // training weight hidden: z.boolean().optional(), // Disallow creation + vision: z.boolean().optional(), // Support image embedding normalization: z.boolean().optional(), // normalization processing batchSize: z.number().optional(), // batch request size defaultConfig: z.record(z.string(), z.any()).optional(), // post request config diff --git a/packages/global/core/dataset/constants.ts b/packages/global/core/dataset/constants.ts index 1bbe49d64d5d..f56cde8c1cd1 100644 --- a/packages/global/core/dataset/constants.ts +++ b/packages/global/core/dataset/constants.ts @@ -275,6 +275,7 @@ export const DatasetSearchModeMap = { export enum SearchScoreTypeEnum { embedding = 'embedding', + imageEmbedding = 'imageEmbedding', fullText = 'fullText', reRank = 'reRank', rrf = 'rrf' @@ -285,6 +286,11 @@ export const SearchScoreTypeMap = { desc: i18nT('common:core.dataset.search.score.embedding desc'), showScore: true }, + [SearchScoreTypeEnum.imageEmbedding]: { + label: i18nT('common:core.dataset.search.score.imageEmbedding'), + desc: i18nT('common:core.dataset.search.score.imageEmbedding desc'), + showScore: true + }, [SearchScoreTypeEnum.fullText]: { label: i18nT('common:core.dataset.search.score.fullText'), desc: i18nT('common:core.dataset.search.score.fullText desc'), diff --git a/packages/global/core/dataset/data/constants.ts b/packages/global/core/dataset/data/constants.ts index 2cc17562ad5a..aca88f696125 100644 --- a/packages/global/core/dataset/data/constants.ts +++ b/packages/global/core/dataset/data/constants.ts @@ -5,7 +5,8 @@ export enum DatasetDataIndexTypeEnum { custom = 'custom', summary = 'summary', question = 'question', - image = 'image' + image = 'image', + imageEmbedding = 'imageEmbedding' } export const DatasetDataIndexMap: Record< @@ -34,6 +35,10 @@ export const DatasetDataIndexMap: Record< [DatasetDataIndexTypeEnum.image]: { label: i18nT('dataset:data_index_image'), color: 'purple' + }, + [DatasetDataIndexTypeEnum.imageEmbedding]: { + label: i18nT('dataset:data_index_image_embedding'), + color: 'purple' } }; export const defaultDatasetIndexData = DatasetDataIndexMap[DatasetDataIndexTypeEnum.custom]; diff --git a/packages/global/core/workflow/runtime/type.ts b/packages/global/core/workflow/runtime/type.ts index 48173408882b..122af4d85afe 100644 --- a/packages/global/core/workflow/runtime/type.ts +++ b/packages/global/core/workflow/runtime/type.ts @@ -205,6 +205,17 @@ export const DispatchNodeResponseSchema = z limit: z.number().optional().meta({ description: '限制' }), searchMode: z.enum(DatasetSearchModeEnum).optional().meta({ description: '搜索模式' }), embeddingWeight: z.number().optional().meta({ description: '嵌入权重' }), + filteredFileCount: z.number().optional().meta({ description: '过滤的非图片文件数量' }), + queryImages: z + .array( + z.object({ + key: z.string().optional(), + url: z.string().optional(), + name: z.string().optional() + }) + ) + .optional() + .meta({ description: '参与知识库检索的图片' }), rerankModel: z.string().optional().meta({ description: '重排模型' }), rerankWeight: z.number().optional().meta({ description: '重排权重' }), reRankInputTokens: z.number().optional().meta({ description: '重排输入 token' }), diff --git a/packages/global/core/workflow/template/system/datasetSearch.ts b/packages/global/core/workflow/template/system/datasetSearch.ts index b7d46f6d50f6..bb4079ef90e5 100644 --- a/packages/global/core/workflow/template/system/datasetSearch.ts +++ b/packages/global/core/workflow/template/system/datasetSearch.ts @@ -126,6 +126,8 @@ export const DatasetSearchModule: FlowNodeTemplateType = { }, { ...Input_Template_UserChatInput, + valueType: WorkflowIOValueTypeEnum.arrayString, + label: i18nT('workflow:content_to_search'), toolDescription: i18nT('workflow:content_to_search') }, { diff --git a/packages/global/openapi/core/dataset/api.ts b/packages/global/openapi/core/dataset/api.ts index 67616947be50..28e7b8c7dc85 100644 --- a/packages/global/openapi/core/dataset/api.ts +++ b/packages/global/openapi/core/dataset/api.ts @@ -315,62 +315,76 @@ export type CreateDatasetFolderBody = z.infer !!data.text.trim() || data.queryImageUrls.length > 0, { + message: 'text or queryImageUrls is required' + }); export type SearchDatasetTestBody = z.infer; export const SearchDatasetTestResponseSchema = z.object({ diff --git a/packages/global/openapi/core/dataset/file/api.ts b/packages/global/openapi/core/dataset/file/api.ts index a08f4318885d..604235c28527 100644 --- a/packages/global/openapi/core/dataset/file/api.ts +++ b/packages/global/openapi/core/dataset/file/api.ts @@ -76,3 +76,68 @@ export const PresignDatasetFilePostUrlResponseSchema = CreatePostPresignedUrlRes export type PresignDatasetFilePostUrlResponse = z.infer< typeof PresignDatasetFilePostUrlResponseSchema >; + +/* ============================================================================ + * API: 上传搜索测试图片 + * Route: POST /api/core/dataset/file/uploadSearchTestImage + * Method: POST + * Description: 上传用于知识库搜索测试的临时图片,上传对象 3 小时后过期 + * Tags: ['Dataset', 'File', 'Write'] + * ============================================================================ */ +export const UploadSearchTestImageBodySchema = z.object({ + datasetId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '知识库 ID' + }) +}); +export type UploadSearchTestImageBody = z.infer; + +export const UploadSearchTestImageResponseSchema = z.object({ + key: z.string().meta({ + example: 'temp/teamId/demo.png', + description: '临时图片 S3 key' + }), + previewUrl: z.string().meta({ + description: '用于前端缩略图展示的临时预览 URL' + }) +}); +export type UploadSearchTestImageResponse = z.infer; + +/* ============================================================================ + * API: 获取搜索测试图片预览 URL + * Route: POST /api/core/dataset/file/getSearchTestImagePreviewUrls + * Method: POST + * Description: 根据搜索测试历史中的临时图片 key 重新生成短期预览 URL + * Tags: ['Dataset', 'File', 'Read'] + * ============================================================================ */ +export const GetSearchTestImagePreviewUrlsBodySchema = z.object({ + datasetId: ObjectIdSchema.meta({ + example: '68ad85a7463006c963799a05', + description: '知识库 ID' + }), + keys: z + .array(z.string().min(1)) + .max(10) + .meta({ + example: ['temp/teamId/demo.png'], + description: '搜索测试图片临时 S3 key 列表,最多 10 个' + }) +}); +export type GetSearchTestImagePreviewUrlsBody = z.infer< + typeof GetSearchTestImagePreviewUrlsBodySchema +>; + +export const GetSearchTestImagePreviewUrlsResponseSchema = z.array( + z.object({ + key: z.string().meta({ + example: 'temp/teamId/demo.png', + description: '临时图片 S3 key' + }), + previewUrl: z.string().meta({ + description: '用于前端缩略图展示的临时预览 URL' + }) + }) +); +export type GetSearchTestImagePreviewUrlsResponse = z.infer< + typeof GetSearchTestImagePreviewUrlsResponseSchema +>; diff --git a/packages/global/openapi/core/dataset/file/index.ts b/packages/global/openapi/core/dataset/file/index.ts index 6e5c69f2c845..9de2ba032a41 100644 --- a/packages/global/openapi/core/dataset/file/index.ts +++ b/packages/global/openapi/core/dataset/file/index.ts @@ -1,10 +1,13 @@ import type { OpenAPIPath } from '../../../type'; import { TagsMap } from '../../../tag'; import { + GetSearchTestImagePreviewUrlsBodySchema, + GetSearchTestImagePreviewUrlsResponseSchema, GetPreviewChunksBodySchema, GetPreviewChunksResponseSchema, PresignDatasetFilePostUrlBodySchema, - PresignDatasetFilePostUrlResponseSchema + PresignDatasetFilePostUrlResponseSchema, + UploadSearchTestImageResponseSchema } from './api'; export const DatasetFilePath: OpenAPIPath = { @@ -55,5 +58,67 @@ export const DatasetFilePath: OpenAPIPath = { } } } + }, + '/core/dataset/file/uploadSearchTestImage': { + post: { + summary: '上传搜索测试图片', + description: '上传用于知识库搜索测试的临时图片,仅支持图片文件,上传对象 3 小时后过期', + tags: [TagsMap.datasetFile], + requestBody: { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + datasetId: { + type: 'string', + description: '知识库 ID' + }, + file: { + type: 'string', + format: 'binary', + description: '图片文件' + } + }, + required: ['datasetId', 'file'] + } + } + } + }, + responses: { + 200: { + description: '成功返回临时图片 key 和缩略图预览 URL', + content: { + 'application/json': { + schema: UploadSearchTestImageResponseSchema + } + } + } + } + } + }, + '/core/dataset/file/getSearchTestImagePreviewUrls': { + post: { + summary: '获取搜索测试图片预览 URL', + description: '根据搜索测试历史中的临时图片 key 重新生成短期预览 URL', + tags: [TagsMap.datasetFile], + requestBody: { + content: { + 'application/json': { + schema: GetSearchTestImagePreviewUrlsBodySchema + } + } + }, + responses: { + 200: { + description: '成功返回临时图片 key 和缩略图预览 URL 列表', + content: { + 'application/json': { + schema: GetSearchTestImagePreviewUrlsResponseSchema + } + } + } + } + } } }; diff --git a/packages/global/openapi/core/dataset/index.ts b/packages/global/openapi/core/dataset/index.ts index 399b58a53fa2..ff4cdc5fff72 100644 --- a/packages/global/openapi/core/dataset/index.ts +++ b/packages/global/openapi/core/dataset/index.ts @@ -183,7 +183,8 @@ export const DatasetPath: OpenAPIPath = { '/core/dataset/searchTest': { post: { summary: '搜索测试', - description: '对知识库执行搜索测试,支持多种搜索模式、重排序和问题扩展', + description: + '对知识库执行搜索测试,支持多种搜索模式、重排序、问题扩展和临时图片 key 检索。图片检索需先调用 /core/dataset/file/uploadSearchTestImage 获取 temp/${teamId}/... key', tags: [TagsMap.datasetCommon], requestBody: { content: { diff --git a/packages/service/common/vectorDB/controller.ts b/packages/service/common/vectorDB/controller.ts index c77af4db30a8..376cdc3cdb37 100644 --- a/packages/service/common/vectorDB/controller.ts +++ b/packages/service/common/vectorDB/controller.ts @@ -100,6 +100,25 @@ export const insertDatasetDataVector = async ({ }; }; +export const insertDatasetDataPrecomputedVector = async ({ + vectors, + ...props +}: InsertVectorControllerPropsType) => { + const { insertIds } = await retryFn(() => + Vector.insert({ + ...props, + vectors + }) + ); + + teamVectorCache.incr(props.teamId, insertIds.length); + + return { + tokens: 0, + insertIds + }; +}; + export const deleteDatasetDataVector: VectorControllerType['delete'] = async (props) => { const result = await retryFn(() => Vector.delete(props)); teamVectorCache.delete(props.teamId); diff --git a/packages/service/core/ai/embedding/index.ts b/packages/service/core/ai/embedding/index.ts index 66b27c4c7ee9..24bb0e123ead 100644 --- a/packages/service/core/ai/embedding/index.ts +++ b/packages/service/core/ai/embedding/index.ts @@ -4,6 +4,7 @@ import { countPromptTokens } from '../../../common/string/tiktoken/index'; import { EmbeddingTypeEnm } from '@fastgpt/global/core/ai/constants'; import { retryFn } from '@fastgpt/global/common/system/utils'; import { getLogger, LogCategories } from '../../../common/logger'; +import { getErrText } from '@fastgpt/global/common/error/utils'; const logger = getLogger(LogCategories.MODULE.AI.EMBEDDING); @@ -14,6 +15,13 @@ type GetVectorProps = { headers?: Record; }; +type GetImageVectorProps = { + model: EmbeddingModelItemType; + imageUrls: string[]; + type?: `${EmbeddingTypeEnm}`; + headers?: Record; +}; + // text to vector export async function getVectorsByText({ model, input, type, headers }: GetVectorProps) { if (!input) { @@ -123,6 +131,78 @@ export async function getVectorsByText({ model, input, type, headers }: GetVecto } } +export async function getVectorsByImage({ model, imageUrls, type, headers }: GetImageVectorProps) { + if (!imageUrls.length) { + return Promise.reject({ + code: 500, + message: 'imageUrls is empty' + }); + } + const ai = getAIApi(); + + try { + const result = await retryFn(() => + ai.embeddings + .create( + { + model: model.model, + input: imageUrls.map((url) => ({ + type: 'image_url', + image_url: { + url + } + })), + encoding_format: 'float', + ...model.defaultConfig, + ...(type === EmbeddingTypeEnm.db && model.dbConfig), + ...(type === EmbeddingTypeEnm.query && model.queryConfig) + } as any, + model.requestUrl + ? { + path: model.requestUrl, + headers: { + ...(model.requestAuth ? { Authorization: `Bearer ${model.requestAuth}` } : {}), + ...headers + } + } + : { headers } + ) + .then(async (res) => { + if (!res.data?.[0]?.embedding) { + logger.error('Image embedding API returned invalid embedding', { + model: model.model, + imageCount: imageUrls.length, + responseDataCount: res.data?.length || 0, + hasUsage: !!res.usage + }); + return Promise.reject('Image embedding API is not responding'); + } + + const vectors = await Promise.all( + res.data.map((item) => + formatVectors(decodeEmbedding(item.embedding), model.normalization) + ) + ); + + return { + tokens: res.usage?.total_tokens || imageUrls.length, + vectors + }; + }) + ); + + return result; + } catch (error) { + logger.error('Image embedding request failed', { + model: model.model, + imageCount: imageUrls.length, + errorMessage: getErrText(error, 'Image embedding request failed') + }); + + return Promise.reject(error); + } +} + export function decodeEmbedding(embedding: number[] | string): number[] { if (typeof embedding === 'string') { // base64-encoded IEEE 754 little-endian float32 array diff --git a/packages/service/core/ai/image.ts b/packages/service/core/ai/image.ts new file mode 100644 index 000000000000..b3008426590a --- /dev/null +++ b/packages/service/core/ai/image.ts @@ -0,0 +1,59 @@ +import { getImageBase64 } from '../../common/file/image/utils'; +import { getS3DatasetSource } from '../../common/s3/sources/dataset'; +import { isS3ObjectKey } from '../../common/s3/utils'; + +export const isValidImageEmbeddingSource = (imageUrl?: string) => { + const url = imageUrl?.trim(); + if (!url) return false; + + if (url.startsWith('data:image/')) return true; + if (isS3ObjectKey(url, 'dataset')) return true; + if (isS3ObjectKey(url, 'temp')) return true; + if (isS3ObjectKey(url, 'chat')) return true; + if (/^https?:\/\//i.test(url)) return true; + + return false; +}; + +export async function normalizeImageToBase64(imageUrl: string) { + if (imageUrl.startsWith('data:image/')) { + return imageUrl; + } + + if ( + isS3ObjectKey(imageUrl, 'dataset') || + isS3ObjectKey(imageUrl, 'temp') || + isS3ObjectKey(imageUrl, 'chat') + ) { + return getS3DatasetSource().getDatasetBase64Image(imageUrl); + } + + const { completeBase64 } = await getImageBase64(imageUrl); + return completeBase64; +} + +export const normalizeImageInputsToBase64 = async ({ + items, + getImageUrl +}: { + items: T[]; + getImageUrl: (item: T) => string; +}) => { + const results = await Promise.all( + items.map(async (item) => { + const imageUrl = getImageUrl(item).trim(); + if (!isValidImageEmbeddingSource(imageUrl)) return; + + try { + return { + item, + imageUrl: await normalizeImageToBase64(imageUrl) + }; + } catch (error) { + return; + } + }) + ); + + return results.filter(Boolean) as { item: T; imageUrl: string }[]; +}; diff --git a/packages/service/core/ai/model.ts b/packages/service/core/ai/model.ts index c1552485312d..12f374763216 100644 --- a/packages/service/core/ai/model.ts +++ b/packages/service/core/ai/model.ts @@ -1,6 +1,9 @@ import { cloneDeep } from 'lodash'; import { type SystemModelItemType } from './type'; -import type { LLMModelItemType } from '@fastgpt/global/core/ai/model.schema'; +import type { + EmbeddingModelItemType, + LLMModelItemType +} from '@fastgpt/global/core/ai/model.schema'; export const getDefaultLLMModel = () => global?.systemDefaultModel.llm!; export const getLLMModel = (model?: string | LLMModelItemType) => { @@ -30,9 +33,14 @@ export const getDefaultHelperBotModel = (): LLMModelItemType => global?.systemDefaultModel.helperBotLLM || getDefaultLLMModel(); export const getDefaultEmbeddingModel = () => global?.systemDefaultModel.embedding!; -export const getEmbeddingModel = (model?: string) => { +export const getEmbeddingModel = (model?: string | EmbeddingModelItemType) => { if (!model) return getDefaultEmbeddingModel(); - return global.embeddingModelMap.get(model) || getDefaultEmbeddingModel(); + return typeof model === 'string' + ? global.embeddingModelMap.get(model) || getDefaultEmbeddingModel() + : model; +}; +export const isImageEmbeddingModel = (model?: string | EmbeddingModelItemType) => { + return !!getEmbeddingModel(model)?.vision; }; export const getDefaultTTSModel = () => global?.systemDefaultModel.tts!; diff --git a/packages/service/core/dataset/collection/controller.ts b/packages/service/core/dataset/collection/controller.ts index 18e2b2180560..5d8184f30af3 100644 --- a/packages/service/core/dataset/collection/controller.ts +++ b/packages/service/core/dataset/collection/controller.ts @@ -1,6 +1,7 @@ import { DatasetCollectionDataProcessModeEnum, - DatasetCollectionTypeEnum + DatasetCollectionTypeEnum, + TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; import { MongoDatasetCollection } from './schema'; import type { @@ -19,7 +20,7 @@ import { predictDataLimitLength } from '../../../../global/core/dataset/utils'; import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { createTrainingUsage } from '../../../support/wallet/usage/controller'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; -import { getLLMModel, getEmbeddingModel, getVlmModel } from '../../ai/model'; +import { getLLMModel, getEmbeddingModel, getVlmModel, isImageEmbeddingModel } from '../../ai/model'; import { pushDataListToTrainingQueue, pushDatasetToParseQueue } from '../training/controller'; import { hashStr } from '@fastgpt/global/common/string/tools'; import { MongoDatasetDataText } from '../data/dataTextSchema'; @@ -74,11 +75,18 @@ export const createCollectionAndInsertData = async ({ // Set default params const trainingType = formatCreateCollectionParams.trainingType || DatasetCollectionDataProcessModeEnum.chunk; - const trainingMode = getTrainingModeByCollection({ + const collectionTrainingMode = getTrainingModeByCollection({ trainingType: trainingType, autoIndexes: formatCreateCollectionParams.autoIndexes, imageIndex: formatCreateCollectionParams.imageIndex }); + const trainingMode = + imageIds && + collectionTrainingMode === TrainingModeEnum.imageParse && + !dataset.vlmModel && + isImageEmbeddingModel(dataset.vectorModel) + ? TrainingModeEnum.chunk + : collectionTrainingMode; if ( trainingType === DatasetCollectionDataProcessModeEnum.qa || @@ -192,7 +200,7 @@ export const createCollectionAndInsertData = async ({ billSource: UsageSourceEnum.training, vectorModel: getEmbeddingModel(dataset.vectorModel)?.name, agentModel: getLLMModel(dataset.agentModel)?.name, - vllmModel: getVlmModel(dataset.vlmModel)?.name, + vllmModel: dataset.vlmModel ? getVlmModel(dataset.vlmModel)?.name : undefined, session }); return newUsageId; diff --git a/packages/service/core/dataset/search/controller.ts b/packages/service/core/dataset/search/controller.ts index b0cea1788faf..1d301aadf90f 100644 --- a/packages/service/core/dataset/search/controller.ts +++ b/packages/service/core/dataset/search/controller.ts @@ -3,9 +3,15 @@ import { DatasetSearchModeMap, SearchScoreTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import { DatasetDataIndexTypeEnum } from '@fastgpt/global/core/dataset/data/constants'; import { recallFromVectorStore } from '../../../common/vectorDB/controller'; -import { getVectorsByText } from '../../ai/embedding'; -import { getEmbeddingModel, getDefaultRerankModel, getLLMModel } from '../../ai/model'; +import { getVectorsByImage, getVectorsByText } from '../../ai/embedding'; +import { + getEmbeddingModel, + getDefaultRerankModel, + getLLMModel, + isImageEmbeddingModel +} from '../../ai/model'; import { MongoDatasetData } from '../data/schema'; import type { DatasetCollectionSchemaType, @@ -37,6 +43,8 @@ import { pushTrack } from '../../../common/middle/tracks/utils'; import { replaceS3KeyToPreviewUrl } from '../../../core/dataset/utils'; import { addDays } from 'date-fns'; import { getLogger, LogCategories } from '../../../common/logger'; +import { createLLMResponse } from '../../ai/llm/request'; +import { normalizeImageToBase64 } from '../../ai/image'; const logger = getLogger(LogCategories.MODULE.DATASET.DATA); @@ -46,9 +54,11 @@ export type SearchDatasetDataProps = { uid?: string; tmbId?: string; model: string; + vlmModel?: string; datasetIds: string[]; reRankQuery: string; queries: string[]; + queryImageUrls?: string[]; [NodeInputKeyEnum.datasetSimilarity]?: number; // min distance [NodeInputKeyEnum.datasetMaxTokens]: number; // max Token limit @@ -93,6 +103,12 @@ export type SearchDatasetDataResponse = { query: string; }; deepSearchResult?: { model: string; inputTokens: number; outputTokens: number }; + imageCaptionResult?: { + model: string; + inputTokens: number; + outputTokens: number; + queries: string[]; + }; }; export const datasetDataReRank = async ({ @@ -179,6 +195,7 @@ export async function searchDatasetData( reRankQuery, queries, model, + vlmModel, similarity = 0, limit: maxTokens, searchMode = DatasetSearchModeEnum.embedding, @@ -189,6 +206,7 @@ export async function searchDatasetData( datasetIds = [], collectionFilterMatch } = props; + const queryImageUrls = props.queryImageUrls || []; // Constants data const datasetDataSelectField = @@ -198,7 +216,7 @@ export async function searchDatasetData( /* init params */ searchMode = DatasetSearchModeMap[searchMode] ? searchMode : DatasetSearchModeEnum.embedding; - usingReRank = usingReRank && !!getDefaultRerankModel(); + let imageCaptionResult: SearchDatasetDataResponse['imageCaptionResult']; // Compatible with topk limit let set = new Set(); @@ -476,6 +494,12 @@ export async function searchDatasetData( tokens: 0 }; } + if (queries.length === 0) { + return { + embeddingRecallResults: [], + tokens: 0 + }; + } const { vectors, tokens } = await getVectorsByText({ model: getEmbeddingModel(model), @@ -608,6 +632,216 @@ export async function searchDatasetData( tokens }; }; + + const getImageCaptionQueries = async (): Promise<{ + queries: string[]; + inputTokens: number; + outputTokens: number; + }> => { + if (!vlmModel || queryImageUrls.length === 0) { + return { + queries: [], + inputTokens: 0, + outputTokens: 0 + }; + } + + const vlmModelData = getLLMModel(vlmModel); + if (!vlmModelData?.vision) { + return { + queries: [], + inputTokens: 0, + outputTokens: 0 + }; + } + + const results = await Promise.all( + queryImageUrls.map(async (url) => { + const { + answerText, + usage: { inputTokens, outputTokens } + } = await createLLMResponse({ + body: { + model: vlmModelData.model, + temperature: 0.1, + stream: true, + useVision: true, + messages: [ + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { + url: await normalizeImageToBase64(url) + } + }, + { + type: 'text', + text: '请用一句话描述这张图片的主体、场景、颜色、文字和关键视觉特征。只输出描述,不要解释。' + } + ] + } + ] as any + } + }); + + return { + query: answerText.trim(), + inputTokens, + outputTokens + }; + }) + ); + + return { + queries: results.map((item) => item.query).filter(Boolean), + inputTokens: results.reduce((sum, item) => sum + item.inputTokens, 0), + outputTokens: results.reduce((sum, item) => sum + item.outputTokens, 0) + }; + }; + + const imageEmbeddingRecall = async ({ + queryImageUrls, + limit, + forbidCollectionIdList, + filterCollectionIdList + }: { + queryImageUrls: string[]; + limit: number; + forbidCollectionIdList: string[]; + filterCollectionIdList?: string[]; + }): Promise<{ + imageEmbeddingRecallResults: SearchDataResponseItemType[][]; + tokens: number; + }> => { + if (limit === 0 || queryImageUrls.length === 0 || !isImageEmbeddingModel(model)) { + return { + imageEmbeddingRecallResults: [], + tokens: 0 + }; + } + + const { vectors, tokens } = await getVectorsByImage({ + model: getEmbeddingModel(model), + imageUrls: await Promise.all(queryImageUrls.map(normalizeImageToBase64)), + type: 'query' + }); + + const recallResults = await Promise.all( + vectors.map(async (vector) => { + return await recallFromVectorStore({ + teamId, + datasetIds, + vector, + limit, + forbidCollectionIdList, + filterCollectionIdList + }); + }) + ); + + const collectionIdList = Array.from( + new Set(recallResults.map((item) => item.results.map((item) => item.collectionId)).flat()) + ); + const indexDataIds = Array.from( + new Set(recallResults.map((item) => item.results.map((item) => item.id?.trim())).flat()) + ); + + const [dataMaps, collectionMaps] = await Promise.all([ + MongoDatasetData.find( + { + teamId, + datasetId: { $in: datasetIds }, + collectionId: { $in: collectionIdList }, + 'indexes.dataId': { $in: indexDataIds }, + 'indexes.type': DatasetDataIndexTypeEnum.imageEmbedding + }, + datasetDataSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + + res.forEach((item) => { + item.indexes.forEach((index) => { + if (index.type === DatasetDataIndexTypeEnum.imageEmbedding) { + map.set(String(index.dataId), item); + } + }); + }); + + return map; + }), + MongoDatasetCollection.find( + { + _id: { $in: collectionIdList } + }, + datsaetCollectionSelectField, + { ...readFromSecondary } + ) + .lean() + .then((res) => { + const map = new Map(); + + res.forEach((item) => { + map.set(String(item._id), item); + }); + + return map; + }) + ]); + + const imageEmbeddingRecallResults = recallResults.map((item) => { + const set = new Set(); + return item.results + .map((item, index) => { + const collection = collectionMaps.get(String(item.collectionId)); + const data = dataMaps.get(String(item.id?.trim())); + if (!collection || !data) return; + + const result: SearchDataResponseItemType = { + id: String(data._id), + updateTime: data.updateTime, + ...formatDatasetDataValue({ + q: data.q, + a: data.a, + imageId: data.imageId, + imageDescMap: data.imageDescMap + }), + chunkIndex: data.chunkIndex, + datasetId: String(data.datasetId), + collectionId: String(data.collectionId), + ...getCollectionSourceData(collection), + score: [ + { + type: SearchScoreTypeEnum.imageEmbedding, + value: item?.score || 0, + index + } + ] + }; + + return result; + }) + .filter((item) => { + if (!item) return false; + if (set.has(item.id)) return false; + set.add(item.id); + return true; + }) + .map((item, index) => ({ + ...item!, + score: item!.score.map((item) => ({ ...item, index })) + })) as SearchDataResponseItemType[]; + }); + + return { + imageEmbeddingRecallResults, + tokens + }; + }; const fullTextRecall = async ({ queries, limit, @@ -782,79 +1016,142 @@ export async function searchDatasetData( fullTextRecallResults }; }; + const concatRecallLists = (lists: SearchDataResponseItemType[][], limit: number) => { + return datasetSearchResultConcat(lists.map((list) => ({ weight: 1, list }))).slice(0, limit); + }; + const concatWeightedRecallLists = ( + lists: { weight: number; list: SearchDataResponseItemType[] }[] + ) => { + return datasetSearchResultConcat( + lists.filter((item) => item.weight > 0 && item.list.length > 0) + ); + }; const multiQueryRecall = async ({ embeddingLimit, - fullTextLimit + fullTextLimit, + textQueries, + imageCaptionQueries }: { embeddingLimit: number; fullTextLimit: number; + textQueries: string[]; + imageCaptionQueries: string[]; }) => { const [{ forbidCollectionIdList }, filterCollectionIdList] = await Promise.all([ getForbidData(), filterCollectionByMetadata() ]); - const [{ tokens, embeddingRecallResults }, { fullTextRecallResults }] = await Promise.all([ + const [ + { tokens: textEmbeddingTokens, embeddingRecallResults }, + { + tokens: imageCaptionEmbeddingTokens, + embeddingRecallResults: imageCaptionEmbeddingRecallResults + }, + { tokens: imageEmbeddingTokens, imageEmbeddingRecallResults }, + { fullTextRecallResults }, + { fullTextRecallResults: imageCaptionFullTextRecallResults } + ] = await Promise.all([ + embeddingRecall({ + queries: textQueries, + limit: embeddingLimit, + forbidCollectionIdList, + filterCollectionIdList + }), embeddingRecall({ - queries, + queries: imageCaptionQueries, + limit: embeddingLimit, + forbidCollectionIdList, + filterCollectionIdList + }), + imageEmbeddingRecall({ + queryImageUrls, limit: embeddingLimit, forbidCollectionIdList, filterCollectionIdList }), fullTextRecall({ - queries, + queries: textQueries, + limit: fullTextLimit, + filterCollectionIdList, + forbidCollectionIdList + }), + fullTextRecall({ + queries: imageCaptionQueries, limit: fullTextLimit, filterCollectionIdList, forbidCollectionIdList }) ]); - // rrf concat - const rrfEmbRecall = datasetSearchResultConcat( - embeddingRecallResults.map((list) => ({ weight: 1, list })) - ).slice(0, embeddingLimit); - const rrfFTRecall = datasetSearchResultConcat( - fullTextRecallResults.map((list) => ({ weight: 1, list })) - ).slice(0, fullTextLimit); - return { - tokens, - embeddingRecallResults: rrfEmbRecall, - fullTextRecallResults: rrfFTRecall + tokens: textEmbeddingTokens + imageCaptionEmbeddingTokens + imageEmbeddingTokens, + textEmbeddingRecallResults: concatRecallLists(embeddingRecallResults, embeddingLimit), + imageCaptionEmbeddingRecallResults: concatRecallLists( + imageCaptionEmbeddingRecallResults, + embeddingLimit + ), + imageEmbeddingRecallResults: concatRecallLists(imageEmbeddingRecallResults, embeddingLimit), + fullTextRecallResults: concatRecallLists(fullTextRecallResults, fullTextLimit), + imageCaptionFullTextRecallResults: concatRecallLists( + imageCaptionFullTextRecallResults, + fullTextLimit + ) }; }; /* main step */ + const imageCaptionQueries = await getImageCaptionQueries(); + if (imageCaptionQueries.queries.length > 0 && vlmModel) { + imageCaptionResult = { + model: getLLMModel(vlmModel).model, + inputTokens: imageCaptionQueries.inputTokens, + outputTokens: imageCaptionQueries.outputTokens, + queries: imageCaptionQueries.queries + }; + } + const activeReRankQuery = reRankQuery; + usingReRank = usingReRank && !!activeReRankQuery && !!getDefaultRerankModel(); + // count limit const { embeddingLimit, fullTextLimit } = countRecallLimit(); // recall const { - embeddingRecallResults, + textEmbeddingRecallResults, + imageCaptionEmbeddingRecallResults, + imageEmbeddingRecallResults, fullTextRecallResults, + imageCaptionFullTextRecallResults, tokens: embeddingTokens } = await multiQueryRecall({ embeddingLimit, - fullTextLimit + fullTextLimit, + textQueries: queries, + imageCaptionQueries: imageCaptionQueries.queries }); + const textRecallResults = concatWeightedRecallLists([ + { weight: embeddingWeight, list: textEmbeddingRecallResults }, + { weight: 1 - embeddingWeight, list: fullTextRecallResults } + ]); + const imageCaptionRecallResults = concatWeightedRecallLists([ + { weight: embeddingWeight, list: imageCaptionEmbeddingRecallResults }, + { weight: 1 - embeddingWeight, list: imageCaptionFullTextRecallResults } + ]); // ReRank results const { results: reRankResults, inputTokens: reRankInputTokens } = await (async () => { - if (!usingReRank) { + if (!usingReRank || textRecallResults.length === 0) { + usingReRank = false; return { results: [], inputTokens: 0 }; } - set = new Set(embeddingRecallResults.map((item) => item.id)); - const concatRecallResults = embeddingRecallResults.concat( - fullTextRecallResults.filter((item) => !set.has(item.id)) - ); - // remove same q and a data set = new Set(); - const filterSameDataResults = concatRecallResults.filter((item) => { + const filterSameDataResults = textRecallResults.filter((item) => { // 删除所有的标点符号与空格等,只对文本进行比较 const str = hashStr(`${item.q}${item.a}`.replace(/[^\p{L}\p{N}]/gu, '')); if (set.has(str)) return false; @@ -864,7 +1161,7 @@ export async function searchDatasetData( try { return await datasetDataReRank({ rerankModel, - query: reRankQuery, + query: activeReRankQuery, data: filterSameDataResults }); } catch (error) { @@ -876,23 +1173,42 @@ export async function searchDatasetData( } })(); - const rrfSearchResult = datasetSearchResultConcat([ - { weight: embeddingWeight, list: embeddingRecallResults }, - { weight: 1 - embeddingWeight, list: fullTextRecallResults } - ]); - const rrfConcatResults = (() => { - if (reRankResults.length === 0) return rrfSearchResult; + const textRerankRecallResults = (() => { + if (reRankResults.length === 0) return textRecallResults; if (rerankWeight === 1) return reRankResults; - return datasetSearchResultConcat([ - { weight: 1 - rerankWeight, list: rrfSearchResult }, + return concatWeightedRecallLists([ + { weight: 1 - rerankWeight, list: textRecallResults }, { weight: rerankWeight, list: reRankResults } ]); })(); + const hasTextQuery = queries.some((item) => item.trim()); + const finalTextRecallWeight = 1; + const finalImageRecallWeight = hasTextQuery ? 0.7 : 1; + const imageRecallResults = concatWeightedRecallLists([ + { + weight: imageCaptionRecallResults.length > 0 ? 0.3 : 0, + list: imageCaptionRecallResults + }, + { + weight: imageEmbeddingRecallResults.length > 0 ? 0.7 : 0, + list: imageEmbeddingRecallResults + } + ]); + const rrfSearchResult = concatWeightedRecallLists([ + { + weight: textRerankRecallResults.length > 0 ? finalTextRecallWeight : 0, + list: textRerankRecallResults + }, + { + weight: imageRecallResults.length > 0 ? finalImageRecallWeight : 0, + list: imageRecallResults + } + ]); // remove same q and a data set = new Set(); - const filterSameDataResults = rrfConcatResults.filter((item) => { + const filterSameDataResults = rrfSearchResult.filter((item) => { // 删除所有的标点符号与空格等,只对文本进行比较 const str = hashStr(`${item.q}${item.a}`.replace(/[^\p{L}\p{N}]/gu, '')); if (set.has(str)) return false; @@ -914,8 +1230,8 @@ export async function searchDatasetData( if (searchMode === DatasetSearchModeEnum.embedding) { usingSimilarityFilter = true; return filterSameDataResults.filter((item) => { - const embeddingScore = item.score.find( - (item) => item.type === SearchScoreTypeEnum.embedding + const embeddingScore = item.score.find((item) => + [SearchScoreTypeEnum.embedding, SearchScoreTypeEnum.imageEmbedding].includes(item.type) ); if (embeddingScore && embeddingScore.value < similarity) return false; return true; @@ -942,7 +1258,8 @@ export async function searchDatasetData( limit: maxTokens, similarity, usingReRank, - usingSimilarityFilter + usingSimilarityFilter, + imageCaptionResult }; } @@ -957,18 +1274,24 @@ export const defaultSearchDatasetData = async ({ datasetSearchExtensionBg, ...props }: DefaultSearchDatasetDataProps): Promise => { - const query = props.queries[0]; + const query = props.queries[0] || ''; const histories = props.histories; - const { searchQueries, reRankQuery, aiExtensionResult } = await datasetSearchQueryExtension({ - query, - llmModel: datasetSearchUsingExtensionQuery - ? getLLMModel(datasetSearchExtensionModel).model - : undefined, - embeddingModel: props.model, - extensionBg: datasetSearchExtensionBg, - histories - }); + const { searchQueries, reRankQuery, aiExtensionResult } = query + ? await datasetSearchQueryExtension({ + query, + llmModel: datasetSearchUsingExtensionQuery + ? getLLMModel(datasetSearchExtensionModel).model + : undefined, + embeddingModel: props.model, + extensionBg: datasetSearchExtensionBg, + histories + }) + : { + searchQueries: [], + reRankQuery: '', + aiExtensionResult: undefined + }; const result = await searchDatasetData({ ...props, diff --git a/packages/service/core/dataset/training/controller.ts b/packages/service/core/dataset/training/controller.ts index ed47d6b0fceb..a55ef8fad6f2 100644 --- a/packages/service/core/dataset/training/controller.ts +++ b/packages/service/core/dataset/training/controller.ts @@ -5,7 +5,7 @@ import type { } from '@fastgpt/global/openapi/core/dataset/data/api'; import { TrainingModeEnum } from '@fastgpt/global/core/dataset/constants'; import { type ClientSession } from '../../../common/mongo'; -import { getLLMModel, getEmbeddingModel, getVlmModel } from '../../ai/model'; +import { getLLMModel, getEmbeddingModel, getVlmModel, isImageEmbeddingModel } from '../../ai/model'; import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { i18nT } from '../../../../web/i18n/utils'; import { getLLMMaxChunkSize } from '../../../../global/core/dataset/training/utils'; @@ -97,6 +97,13 @@ export const pushDataListToTrainingQueue = async ({ if (mode === TrainingModeEnum.image || mode === TrainingModeEnum.imageParse) { const vllmModelData = getVlmModel(vlmModel); if (!vllmModelData) { + if (isImageEmbeddingModel(vectorModelData)) { + return { + maxToken: Infinity, + model: vectorModelData.model, + weight: vectorModelData.weight + }; + } return Promise.reject(i18nT('common:error_vlm_not_config')); } return { diff --git a/packages/service/core/workflow/dispatch/ai/agent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/index.ts index 1e498ec77e58..689168fdc58c 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/index.ts @@ -152,6 +152,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise const { filesMap, allFilesMap, + queryImageUrls, prompt: fileInputPrompt } = formatFileInput({ fileUrls: fileLinks, @@ -477,6 +478,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise steps: agentPlan.steps, // 传入所有步骤,而不仅仅是未执行的步骤 step, filesMap, + queryImageUrls, capabilityToolCallHandler }); const stepCallErrorText = @@ -602,6 +604,7 @@ export const dispatchRunAgent = async (props: DispatchAgentModuleProps): Promise getSubApp, completionTools: agentCompletionTools, filesMap, + queryImageUrls, capabilityToolCallHandler }); nodeResponses.push(result.nodeResponse); diff --git a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts index 1255f9f05f36..425a197f3c98 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/master/call.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/master/call.ts @@ -52,6 +52,7 @@ export const masterCall = async ({ getSubApp, completionTools, filesMap, + queryImageUrls, steps, step, capabilityToolCallHandler, @@ -65,6 +66,7 @@ export const masterCall = async ({ getSubApp: (id: string) => SubAppRuntimeType | undefined; completionTools: ChatCompletionTool[]; filesMap: Record; + queryImageUrls?: string[]; // Step call steps?: AgentStepItemType[]; @@ -207,6 +209,7 @@ export const masterCall = async ({ getSubApp, completionTools, filesMap, + queryImageUrls, capabilityToolCallHandler }); diff --git a/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts b/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts index 9b053e0e6cfc..b9a2b3cbdb9a 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/piAgent/index.ts @@ -79,6 +79,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< const { filesMap, allFilesMap, + queryImageUrls, prompt: fileInputPrompt } = formatFileInput({ fileUrls: fileLinks, @@ -174,6 +175,7 @@ export const dispatchPiAgent = async (props: DispatchAgentModuleProps): Promise< getSubApp, completionTools: agentCompletionTools, filesMap, + queryImageUrls, capabilityToolCallHandler }; diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts index 10b95afbf76a..1c67bb0b5b7a 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/dataset/index.ts @@ -19,6 +19,7 @@ import type { DispatchSubAppResponse } from '../../type'; import type { AppFormEditFormType } from '@fastgpt/global/core/app/formEdit/type'; import { DatasetSearchToolSchema } from './utils'; import { parseJsonArgs } from '../../../../../../ai/utils'; +import { formatQueryImages } from '../../../../../utils/context'; const logger = getLogger(LogCategories.MODULE.AI.AGENT); type DatasetSearchParams = { @@ -27,6 +28,7 @@ type DatasetSearchParams = { args: string; llmModel: string; datasetParams?: AppFormEditFormType['dataset']; + queryImageUrls?: string[]; }; /** @@ -148,6 +150,7 @@ ${chunkSummaries} export const dispatchAgentDatasetSearch = async ({ args, datasetParams, + queryImageUrls, teamId, tmbId, llmModel @@ -187,6 +190,7 @@ export const dispatchAgentDatasetSearch = async ({ teamId, reRankQuery: query, queries: [query], + queryImageUrls, model: vectorModel.model, similarity: datasetParams.similarity ?? 0.4, limit: datasetParams.limit || 5000, @@ -292,6 +296,7 @@ export const dispatchAgentDatasetSearch = async ({ moduleType: FlowNodeTypeEnum.datasetSearchNode, moduleName: i18nT('chat:dataset_search'), query, + queryImages: formatQueryImages(queryImageUrls), embeddingModel: vectorModel.name, embeddingTokens, similarity: usingSimilarityFilter ? searchData.similarity : undefined, diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/file/utils.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/file/utils.ts index 7e16c646d1a0..2a32b4d7ac99 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/file/utils.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/file/utils.ts @@ -46,6 +46,7 @@ export const formatFileInput = ({ }): { filesMap: Record; allFilesMap: Record; + queryImageUrls: string[]; prompt: string; } => { const filesFromHistories = getHistoryFileLinks(histories); @@ -54,6 +55,7 @@ export const formatFileInput = ({ return { filesMap: {}, allFilesMap: {}, + queryImageUrls: [], prompt: '' }; } @@ -65,7 +67,7 @@ export const formatFileInput = ({ if (typeof url !== 'string') return false; // 检查相对路径 - const validPrefixList = ['/', 'http', 'ws']; + const validPrefixList = ['/', 'http', 'ws', 'data:', 'dataset/', 'chat/', 'temp/']; if (validPrefixList.some((prefix) => url.startsWith(prefix))) { return true; } @@ -90,17 +92,25 @@ export const formatFileInput = ({ }) .filter(Boolean) .slice(0, maxFiles) - .map(parseUrlToFileType) as { - type: `${ChatFileTypeEnum}`; - name: string; - url: string; - }[]; + .map(parseUrlToFileType) + .filter( + ( + item + ): item is { + type: ChatFileTypeEnum; + name: string; + url: string; + } => !!item?.name && !!item.url + ); return parseResult; }; const historyParseResult = parseFn(filesFromHistories); const queryParseResult = parseFn(fileUrls); + const queryImageUrls = queryParseResult + .filter((file) => file.type === ChatFileTypeEnum.image) + .map((file) => file.url); // 去重:基于文件名去重,避免历史记录和当前请求中的文件重复(避免 plan agent ask 之后的文件二次传入) // 优先使用新的 URL(queryParseResult),因为预签名 URL 有过期时间,新的更不容易过期 @@ -175,6 +185,7 @@ ${ return { filesMap, allFilesMap, + queryImageUrls, prompt }; }; diff --git a/packages/service/core/workflow/dispatch/ai/agent/utils.ts b/packages/service/core/workflow/dispatch/ai/agent/utils.ts index 55b7927513ae..78be6f4f0c03 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/utils.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/utils.ts @@ -128,6 +128,7 @@ export type ToolDispatchContext = Pick< getSubApp: (id: string) => SubAppRuntimeType | undefined; completionTools: ChatCompletionTool[]; filesMap: Record; + queryImageUrls?: string[]; capabilityToolCallHandler?: CapabilityToolCallHandlerType; streamResponseFn?: (args: WorkflowResponseItemType) => void | undefined; }; @@ -137,6 +138,7 @@ export const getExecuteTool = ({ getSubApp, completionTools, filesMap, + queryImageUrls, capabilityToolCallHandler, checkIsStopping, chatConfig, @@ -228,6 +230,7 @@ export const getExecuteTool = ({ const result = await dispatchAgentDatasetSearch({ args: args, datasetParams, + queryImageUrls, teamId: runningUserInfo.teamId, tmbId: runningUserInfo.tmbId, llmModel: model diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/index.ts b/packages/service/core/workflow/dispatch/ai/toolcall/index.ts index ff269db46cf0..36586bec551a 100644 --- a/packages/service/core/workflow/dispatch/ai/toolcall/index.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/index.ts @@ -7,7 +7,7 @@ import { runToolCall } from './toolCall'; import type { FileInputType } from './type'; import { type DispatchToolModuleProps, type ToolNodeItemType } from './type'; import type { UserChatItemFileItemType, ChatItemMiniType } from '@fastgpt/global/core/chat/type'; -import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { GPTMessages2Chats, chats2GPTMessages, @@ -121,6 +121,9 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< const { userFiles } = await getMultiInput({ fileLinks }); + const queryImageUrls = userFiles + .filter((file) => file.type === ChatFileTypeEnum.image) + .map((file) => file.url); const concatenateSystemPrompt = [toolModel.defaultSystemChatPrompt, systemPrompt] .filter(Boolean) @@ -226,6 +229,7 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< toolNodes, toolModel, messages: adaptMessages, + queryImageUrls, childrenInteractiveParams: lastInteractive?.type === 'toolChildrenInteractive' ? lastInteractive.params : undefined }); diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts b/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts index 8de5d36a0a83..e198dc6196f6 100644 --- a/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts @@ -9,7 +9,12 @@ import { runWorkflow } from '../../index'; import type { ChildResponseItemType, DispatchToolModuleProps, ToolNodeItemType } from './type'; import { chats2GPTMessages, GPTMessages2Chats } from '@fastgpt/global/core/chat/adapt'; import type { AIChatItemValueItemType } from '@fastgpt/global/core/chat/type'; -import { formatToolResponse, initToolCallEdges, initToolNodes } from './utils'; +import { + formatToolResponse, + initToolCallEdges, + initToolNodes, + mergeDatasetToolQueryImages +} from './utils'; import { parseJsonArgs } from '../../../../ai/utils'; import { sliceStrStartEnd } from '@fastgpt/global/common/string/tools'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; @@ -53,6 +58,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise; currentInputFiles: FileInputType[]; + queryImageUrls?: string[]; }; export type ToolNodeItemType = { diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/utils.ts b/packages/service/core/workflow/dispatch/ai/toolcall/utils.ts index 529d4182c79e..8969e3f38faa 100644 --- a/packages/service/core/workflow/dispatch/ai/toolcall/utils.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/utils.ts @@ -1,5 +1,7 @@ import { sliceStrStartEnd } from '@fastgpt/global/common/string/tools'; import { type AIChatItemValueItemType } from '@fastgpt/global/core/chat/type'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { type FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; import { type RuntimeEdgeItemType } from '@fastgpt/global/core/workflow/type/edge'; import { type RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; @@ -67,3 +69,36 @@ export const initToolNodes = ( } }); }; + +export const mergeDatasetToolQueryImages = ({ + flowNodeType, + startParams, + queryImageUrls = [] +}: { + flowNodeType: RuntimeNodeItemType['flowNodeType']; + startParams: Record; + queryImageUrls?: string[]; +}) => { + if (flowNodeType !== FlowNodeTypeEnum.datasetSearchNode || queryImageUrls.length === 0) { + return startParams; + } + + const queryInput = startParams[NodeInputKeyEnum.userChatInput]; + const queryList = Array.isArray(queryInput) + ? queryInput + : queryInput === undefined + ? [] + : [queryInput]; + const userChatInput = Array.from( + new Set( + [...queryList, ...queryImageUrls].filter( + (item): item is string => typeof item === 'string' && item.trim() !== '' + ) + ) + ); + + return { + ...startParams, + [NodeInputKeyEnum.userChatInput]: userChatInput + }; +}; diff --git a/packages/service/core/workflow/dispatch/dataset/search.ts b/packages/service/core/workflow/dispatch/dataset/search.ts index 45f3c79784eb..7b26d73901e9 100644 --- a/packages/service/core/workflow/dispatch/dataset/search.ts +++ b/packages/service/core/workflow/dispatch/dataset/search.ts @@ -15,6 +15,8 @@ import { filterDatasetsByTmbId } from '../../../dataset/utils'; import { getDatasetSearchToolResponsePrompt } from '@fastgpt/global/core/ai/prompt/dataset.const'; import { getNodeErrResponse } from '../utils'; import { getLogger, LogCategories } from '../../../../common/logger'; +import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants'; +import { formatQueryImages, getWorkflowContext, parseUrlToFileType } from '../../utils/context'; const logger = getLogger(LogCategories.MODULE.WORKFLOW.DATASET); @@ -22,7 +24,7 @@ type DatasetSearchProps = ModuleDispatchProps<{ [NodeInputKeyEnum.datasetSelectList]: SelectedDatasetType[]; [NodeInputKeyEnum.datasetSimilarity]: number; [NodeInputKeyEnum.datasetMaxTokens]: number; - [NodeInputKeyEnum.userChatInput]?: string; + [NodeInputKeyEnum.userChatInput]?: string | string[]; [NodeInputKeyEnum.datasetSearchMode]: DatasetSearchModeEnum; [NodeInputKeyEnum.datasetSearchEmbeddingWeight]?: number; @@ -46,6 +48,43 @@ export type DatasetSearchResponse = DispatchNodeResultType<{ [NodeOutputKeyEnum.datasetQuoteQA]: SearchDataResponseItemType[]; }>; +const isLikelyFileLinkValue = (input: string) => { + if (/^(data:|dataset\/|chat\/|temp\/)/i.test(input)) return true; + if (getWorkflowContext()?.queryUrlTypeMap?.[input]) return true; + return false; +}; + +const normalizeDatasetSearchInput = (input?: string | string[]) => { + const inputList = (Array.isArray(input) ? input : [input]) + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean); + + const textQueries: string[] = []; + const queryImageUrls: string[] = []; + let filteredFileCount = 0; + + for (const item of inputList) { + if (isLikelyFileLinkValue(item)) { + const fileInfo = parseUrlToFileType(item); + if (fileInfo?.type === ChatFileTypeEnum.image) { + queryImageUrls.push(item); + } else { + filteredFileCount++; + } + continue; + } + + textQueries.push(item); + } + + return { + textQueries, + queryImageUrls, + filteredFileCount + }; +}; + export async function dispatchDatasetSearch( props: DatasetSearchProps ): Promise { @@ -99,7 +138,12 @@ export async function dispatchDatasetSearch( [DispatchNodeResponseKeyEnum.toolResponses]: [] }; - if (!userChatInput) { + const { textQueries, queryImageUrls, filteredFileCount } = + normalizeDatasetSearchInput(userChatInput); + const normalizedUserChatInput = textQueries.join('\n'); + const queryImages = formatQueryImages(queryImageUrls); + + if (!normalizedUserChatInput && queryImageUrls.length === 0) { return emptyResult; } @@ -116,9 +160,11 @@ export async function dispatchDatasetSearch( } // Get vector model - const vectorModel = getEmbeddingModel( - (await MongoDataset.findById(datasets[0].datasetId, 'vectorModel').lean())?.vectorModel - ); + const dataset = await MongoDataset.findById( + datasets[0].datasetId, + 'vectorModel vlmModel' + ).lean(); + const vectorModel = getEmbeddingModel(dataset?.vectorModel); // Get Rerank Model const rerankModelData = getRerankModel(rerankModel); @@ -126,9 +172,11 @@ export async function dispatchDatasetSearch( const searchData = { histories, teamId, - reRankQuery: userChatInput, - queries: [userChatInput], + reRankQuery: normalizedUserChatInput, + queries: textQueries, + queryImageUrls, model: vectorModel.model, + vlmModel: dataset?.vlmModel, similarity, limit, datasetIds, @@ -146,8 +194,9 @@ export async function dispatchDatasetSearch( usingSimilarityFilter, usingReRank: searchUsingReRank, queryExtensionResult, + imageCaptionResult, deepSearchResult - } = datasetDeepSearch + } = datasetDeepSearch && textQueries.length > 0 ? await deepRagSearch({ ...searchData, datasetDeepSearchModel, @@ -218,7 +267,22 @@ export async function dispatchDatasetSearch( outputTokens: 0 }); } - // 4. Deep search + // 4. Image caption + if (imageCaptionResult) { + const { totalPoints, modelName } = formatModelChars2Points({ + model: imageCaptionResult.model, + inputTokens: imageCaptionResult.inputTokens, + outputTokens: imageCaptionResult.outputTokens + }); + nodeUsages.push({ + totalPoints, + moduleName: i18nT('account_usage:image_parse'), + model: modelName, + inputTokens: imageCaptionResult.inputTokens, + outputTokens: imageCaptionResult.outputTokens + }); + } + // 5. Deep search if (deepSearchResult) { const { totalPoints, modelName } = formatModelChars2Points({ model: deepSearchResult.model, @@ -243,7 +307,9 @@ export async function dispatchDatasetSearch( }, [DispatchNodeResponseKeyEnum.nodeResponse]: { totalPoints, - query: userChatInput, + query: normalizedUserChatInput, + queryImages, + filteredFileCount, embeddingModel: vectorModel.name, embeddingTokens, similarity: usingSimilarityFilter ? similarity : undefined, diff --git a/packages/service/core/workflow/dispatch/init/workflowStart.tsx b/packages/service/core/workflow/dispatch/init/workflowStart.tsx index 621374c41eb4..1d24fa2df2e0 100644 --- a/packages/service/core/workflow/dispatch/init/workflowStart.tsx +++ b/packages/service/core/workflow/dispatch/init/workflowStart.tsx @@ -1,6 +1,7 @@ import { chatValue2RuntimePrompt } from '@fastgpt/global/core/chat/adapt'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { updateWorkflowContextVal } from '../../utils/context'; import type { DispatchNodeResultType, ModuleDispatchProps @@ -31,6 +32,18 @@ export const dispatchWorkflowStart = async (props: Record): Promise const variablesFiles: string[] = Array.isArray(variables?.fileUrlList) ? variables.fileUrlList : []; + const queryUrlTypeMap = files.reduce>( + (acc, item) => { + if (item?.url) { + acc[item.url] = item.type; + } + return acc; + }, + {} + ); + updateWorkflowContextVal({ + queryUrlTypeMap + }); return { [DispatchNodeResponseKeyEnum.nodeResponse]: {}, diff --git a/packages/service/core/workflow/utils/context.ts b/packages/service/core/workflow/utils/context.ts index 2874e54aadf3..b8c96ad6236a 100644 --- a/packages/service/core/workflow/utils/context.ts +++ b/packages/service/core/workflow/utils/context.ts @@ -110,3 +110,21 @@ export const parseUrlToFileType = (url: string): UserChatItemFileItemType | unde }; } }; + +const isObjectKeyUrl = (url: string) => /^(temp|chat|dataset)\//i.test(url); + +export const formatQueryImages = (queryImageUrls?: string[]) => { + const images = (queryImageUrls || []) + .map((url) => { + const fileInfo = parseUrlToFileType(url); + if (fileInfo?.type !== ChatFileTypeEnum.image) return; + + return { + ...(isObjectKeyUrl(url) ? { key: url } : { url }), + name: fileInfo.name + }; + }) + .filter(Boolean) as { key?: string; url?: string; name?: string }[]; + + return images.length > 0 ? images : undefined; +}; diff --git a/packages/service/test/core/ai/embedding/index.test.ts b/packages/service/test/core/ai/embedding/index.test.ts index 88d87ed0301f..214da3317db0 100644 --- a/packages/service/test/core/ai/embedding/index.test.ts +++ b/packages/service/test/core/ai/embedding/index.test.ts @@ -648,3 +648,74 @@ describe('getVectorsByText function test', () => { }); }); }); + +describe('getVectorsByImage function test', () => { + let getVectorsByImage: (typeof import('@fastgpt/service/core/ai/embedding/index'))['getVectorsByImage']; + + beforeAll(async () => { + const actual = await vi.importActual( + '@fastgpt/service/core/ai/embedding/index' + ); + getVectorsByImage = actual.getVectorsByImage; + }); + + beforeEach(() => { + mockCreate.mockReset(); + }); + + const buildModel = (overrides: Partial = {}): EmbeddingModelItemType => + ({ + model: 'multimodal-embedding', + name: 'multimodal-embedding', + normalization: false, + ...overrides + }) as EmbeddingModelItemType; + + const makeResponse = ( + embeddings: Array, + opts: { usage?: { total_tokens: number } } = {} + ) => ({ + data: embeddings.map((embedding) => ({ embedding })), + usage: opts.usage + }); + + it('should pass base64 image urls to embedding API request body', async () => { + const base64Image = 'data:image/png;base64,base64image'; + mockCreate.mockResolvedValue( + makeResponse([[0.1, 0.2, 0.3, 0.4]], { usage: { total_tokens: 5 } }) + ); + + const result = await getVectorsByImage({ + model: buildModel(), + imageUrls: [base64Image], + type: EmbeddingTypeEnm.db + }); + + expect(mockCreate).toHaveBeenCalledTimes(1); + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + model: 'multimodal-embedding', + input: [ + { + type: 'image_url', + image_url: { + url: base64Image + } + } + ], + encoding_format: 'float' + }); + expect(mockCreate.mock.calls[0][0].input[0].image_url.url).not.toContain( + '/api/system/file/download' + ); + expect(result.tokens).toBe(5); + expect(result.vectors[0]).toHaveLength(1536); + }); + + it('should reject when image urls is empty', async () => { + await expect(getVectorsByImage({ model: buildModel(), imageUrls: [] })).rejects.toMatchObject({ + code: 500, + message: 'imageUrls is empty' + }); + expect(mockCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/service/test/core/ai/image.test.ts b/packages/service/test/core/ai/image.test.ts new file mode 100644 index 000000000000..44e93039de59 --- /dev/null +++ b/packages/service/test/core/ai/image.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { + isValidImageEmbeddingSource, + normalizeImageInputsToBase64, + normalizeImageToBase64 +} from '@fastgpt/service/core/ai/image'; +import { getImageBase64 } from '@fastgpt/service/common/file/image/utils'; +import { getS3DatasetSource } from '@fastgpt/service/common/s3/sources/dataset'; + +vi.mock('@fastgpt/service/common/file/image/utils', () => ({ + getImageBase64: vi.fn() +})); + +vi.mock('@fastgpt/service/common/s3/sources/dataset', () => ({ + getS3DatasetSource: vi.fn() +})); + +const mockGetImageBase64 = vi.mocked(getImageBase64); +const mockGetS3DatasetSource = vi.mocked(getS3DatasetSource); +const mockGetDatasetBase64Image = vi.fn(); + +describe('normalizeImageToBase64', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetS3DatasetSource.mockReturnValue({ + getDatasetBase64Image: mockGetDatasetBase64Image + } as any); + mockGetDatasetBase64Image.mockResolvedValue('data:image/png;base64,s3image'); + mockGetImageBase64.mockResolvedValue({ + completeBase64: 'data:image/jpeg;base64,remoteimage', + base64: 'remoteimage' + }); + }); + + it('should preserve data image url', async () => { + const dataUrl = 'data:image/png;base64,exists'; + + await expect(normalizeImageToBase64(dataUrl)).resolves.toBe(dataUrl); + expect(mockGetDatasetBase64Image).not.toHaveBeenCalled(); + expect(mockGetImageBase64).not.toHaveBeenCalled(); + }); + + it.each([ + 'dataset/dataset-id/image.png', + 'temp/team-id/image.png', + 'chat/app/user/chat/image.png' + ])('should load private s3 key %s as base64', async (key) => { + await expect(normalizeImageToBase64(key)).resolves.toBe('data:image/png;base64,s3image'); + + expect(mockGetDatasetBase64Image).toHaveBeenCalledWith(key); + expect(mockGetImageBase64).not.toHaveBeenCalled(); + }); + + it('should load external image url as base64', async () => { + await expect(normalizeImageToBase64('https://example.com/image.jpg')).resolves.toBe( + 'data:image/jpeg;base64,remoteimage' + ); + + expect(mockGetImageBase64).toHaveBeenCalledWith('https://example.com/image.jpg'); + expect(mockGetDatasetBase64Image).not.toHaveBeenCalled(); + }); + + it('should reject when image loading fails', async () => { + const error = new Error('download failed'); + mockGetImageBase64.mockRejectedValueOnce(error); + + await expect(normalizeImageToBase64('https://example.com/broken.jpg')).rejects.toBe(error); + }); +}); + +describe('isValidImageEmbeddingSource', () => { + it.each([ + 'data:image/png;base64,exists', + 'dataset/dataset-id/image.png', + 'temp/team-id/image.png', + 'chat/app/user/chat/image.png', + 'https://example.com/image.jpg', + 'http://example.com/image.jpg' + ])('should accept supported image source %s', (url) => { + expect(isValidImageEmbeddingSource(url)).toBe(true); + }); + + it.each([ + '', + './image.png', + 'images/image.png', + '/Users/test/image.png', + 'file:///tmp/image.png' + ])('should reject unsupported image source %s', (url) => { + expect(isValidImageEmbeddingSource(url)).toBe(false); + }); +}); + +describe('normalizeImageInputsToBase64', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetS3DatasetSource.mockReturnValue({ + getDatasetBase64Image: mockGetDatasetBase64Image + } as any); + mockGetDatasetBase64Image.mockResolvedValue('data:image/png;base64,s3image'); + }); + + it('should skip unsupported or failed image sources', async () => { + mockGetImageBase64.mockImplementation(async (url) => { + if (url === 'https://example.com/broken.jpg') { + throw new Error('download failed'); + } + + return { + completeBase64: `data:image/jpeg;base64,${url}`, + base64: String(url) + }; + }); + + const result = await normalizeImageInputsToBase64({ + items: [ + 'https://example.com/ok.jpg', + './local.png', + 'https://example.com/broken.jpg', + 'dataset/dataset-id/image.png' + ], + getImageUrl: (item) => item + }); + + expect(result).toEqual([ + { + item: 'https://example.com/ok.jpg', + imageUrl: 'data:image/jpeg;base64,https://example.com/ok.jpg' + }, + { + item: 'dataset/dataset-id/image.png', + imageUrl: 'data:image/png;base64,s3image' + } + ]); + expect(mockGetImageBase64).toHaveBeenCalledWith('https://example.com/ok.jpg'); + expect(mockGetImageBase64).toHaveBeenCalledWith('https://example.com/broken.jpg'); + expect(mockGetImageBase64).not.toHaveBeenCalledWith('./local.png'); + }); +}); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/sub/dataset/index.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/sub/dataset/index.test.ts new file mode 100644 index 000000000000..0856252a73e7 --- /dev/null +++ b/packages/service/test/core/workflow/dispatch/ai/agent/sub/dataset/index.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants'; + +const mockDefaultSearchDatasetData = vi.hoisted(() => vi.fn()); +const mockMongoDatasetFindById = vi.hoisted(() => vi.fn()); + +vi.mock('@fastgpt/service/core/dataset/search/controller', () => ({ + defaultSearchDatasetData: mockDefaultSearchDatasetData +})); + +vi.mock('@fastgpt/service/core/dataset/schema', () => ({ + MongoDataset: { + findById: mockMongoDatasetFindById + } +})); + +vi.mock('@fastgpt/service/core/ai/model', () => ({ + getEmbeddingModel: vi.fn(() => ({ + model: 'mock-embedding-model', + name: 'Mock Embedding Model' + })), + getLLMModel: vi.fn(() => ({ + maxContext: 8000 + })), + getRerankModel: vi.fn(() => undefined) +})); + +vi.mock('@fastgpt/service/common/string/tiktoken/index', () => ({ + countPromptTokens: vi.fn().mockResolvedValue(0) +})); + +vi.mock('@fastgpt/service/support/wallet/usage/utils', () => ({ + formatModelChars2Points: vi.fn(() => ({ + totalPoints: 0, + modelName: 'Mock Model' + })) +})); + +import { dispatchAgentDatasetSearch } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/dataset'; + +describe('dispatchAgentDatasetSearch', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockMongoDatasetFindById.mockReturnValue({ + lean: vi.fn().mockResolvedValue({ + vectorModel: { + model: 'mock-embedding-model' + } + }) + }); + + mockDefaultSearchDatasetData.mockResolvedValue({ + searchRes: [], + embeddingTokens: 3, + reRankInputTokens: 0, + usingSimilarityFilter: false, + usingReRank: false, + queryExtensionResult: undefined + }); + }); + + it('should pass current image urls into dataset search and node response', async () => { + const result = await dispatchAgentDatasetSearch({ + args: JSON.stringify({ query: 'find similar product' }), + datasetParams: { + datasets: [ + { + datasetId: 'dataset-1', + name: 'Dataset', + avatar: '' + } + ], + similarity: 0.6, + limit: 1500, + searchMode: DatasetSearchModeEnum.embedding, + embeddingWeight: 0.7, + usingReRank: false, + rerankModel: '', + rerankWeight: 0.5, + datasetSearchUsingExtensionQuery: false, + datasetSearchExtensionModel: '', + datasetSearchExtensionBg: '' + } as any, + queryImageUrls: ['/api/file/current.png'], + teamId: 'team-1', + tmbId: 'tmb-1', + llmModel: 'gpt-4o' + }); + + expect(mockDefaultSearchDatasetData).toHaveBeenCalledWith( + expect.objectContaining({ + queries: ['find similar product'], + queryImageUrls: ['/api/file/current.png'], + datasetIds: ['dataset-1'], + teamId: 'team-1' + }) + ); + + expect(result.nodeResponse?.queryImages).toEqual([ + { + url: '/api/file/current.png', + name: 'current.png' + } + ]); + }); +}); diff --git a/packages/service/test/core/workflow/dispatch/ai/agent/sub/file/utils.test.ts b/packages/service/test/core/workflow/dispatch/ai/agent/sub/file/utils.test.ts new file mode 100644 index 000000000000..5676067642ef --- /dev/null +++ b/packages/service/test/core/workflow/dispatch/ai/agent/sub/file/utils.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { ChatFileTypeEnum, ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; +import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; +import { formatFileInput } from '@fastgpt/service/core/workflow/dispatch/ai/agent/sub/file/utils'; + +describe('formatFileInput', () => { + it('should return empty image urls when no files are provided', () => { + const result = formatFileInput({ + fileUrls: [], + maxFiles: 20, + histories: [], + useSkill: false + }); + + expect(result.queryImageUrls).toEqual([]); + expect(result.filesMap).toEqual({}); + expect(result.allFilesMap).toEqual({}); + }); + + it('should collect only current user image urls for dataset image search', () => { + const historyImageUrl = '/api/file/history.png'; + const histories: ChatItemMiniType[] = [ + { + obj: ChatRoleEnum.Human, + value: [ + { + file: { + type: ChatFileTypeEnum.image, + name: 'history.png', + url: historyImageUrl + } + } + ] + } + ]; + + const result = formatFileInput({ + fileUrls: [ + 'https://fastgpt.local/api/file/current.png', + '/api/file/manual.pdf', + 'data:image/png;base64,aaa', + 'data:text/plain;base64,bbb', + 'local/path/invalid.png' + ], + requestOrigin: 'https://fastgpt.local', + maxFiles: 20, + histories, + useSkill: false + }); + + expect(result.queryImageUrls).toEqual(['/api/file/current.png', 'data:image/png;base64,aaa']); + expect(result.queryImageUrls).not.toContain(historyImageUrl); + expect(result.filesMap).toEqual({ + '2': '/api/file/manual.pdf' + }); + expect(Object.values(result.allFilesMap).map((file) => file.url)).toEqual([ + '/api/file/current.png', + '/api/file/manual.pdf', + 'data:image/png;base64,aaa', + historyImageUrl + ]); + }); +}); diff --git a/packages/service/test/core/workflow/dispatch/ai/toolcall/utils.test.ts b/packages/service/test/core/workflow/dispatch/ai/toolcall/utils.test.ts new file mode 100644 index 000000000000..f2637bc53b00 --- /dev/null +++ b/packages/service/test/core/workflow/dispatch/ai/toolcall/utils.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; +import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; +import { mergeDatasetToolQueryImages } from '../../../../../../core/workflow/dispatch/ai/toolcall/utils'; + +describe('mergeDatasetToolQueryImages', () => { + it('should append image urls to dataset tool string query', () => { + const result = mergeDatasetToolQueryImages({ + flowNodeType: FlowNodeTypeEnum.datasetSearchNode, + startParams: { + [NodeInputKeyEnum.userChatInput]: 'black high heels' + }, + queryImageUrls: ['/api/file/current.png'] + }); + + expect(result[NodeInputKeyEnum.userChatInput]).toEqual([ + 'black high heels', + '/api/file/current.png' + ]); + }); + + it('should append image urls to dataset tool array query', () => { + const result = mergeDatasetToolQueryImages({ + flowNodeType: FlowNodeTypeEnum.datasetSearchNode, + startParams: { + [NodeInputKeyEnum.userChatInput]: ['black high heels', 'red sole'] + }, + queryImageUrls: ['/api/file/current.png'] + }); + + expect(result[NodeInputKeyEnum.userChatInput]).toEqual([ + 'black high heels', + 'red sole', + '/api/file/current.png' + ]); + }); + + it('should deduplicate merged query image urls while preserving order', () => { + const result = mergeDatasetToolQueryImages({ + flowNodeType: FlowNodeTypeEnum.datasetSearchNode, + startParams: { + [NodeInputKeyEnum.userChatInput]: ['black high heels', '/api/file/current.png'] + }, + queryImageUrls: ['/api/file/current.png', '/api/file/second.png'] + }); + + expect(result[NodeInputKeyEnum.userChatInput]).toEqual([ + 'black high heels', + '/api/file/current.png', + '/api/file/second.png' + ]); + }); + + it('should not inject image urls into non-dataset tools', () => { + const startParams = { + [NodeInputKeyEnum.userChatInput]: 'black high heels' + }; + + const result = mergeDatasetToolQueryImages({ + flowNodeType: FlowNodeTypeEnum.httpRequest468, + startParams, + queryImageUrls: ['/api/file/current.png'] + }); + + expect(result).toBe(startParams); + expect(result[NodeInputKeyEnum.userChatInput]).toBe('black high heels'); + }); + + it('should keep dataset tool params unchanged when there are no image urls', () => { + const startParams = { + [NodeInputKeyEnum.userChatInput]: 'black high heels' + }; + + const result = mergeDatasetToolQueryImages({ + flowNodeType: FlowNodeTypeEnum.datasetSearchNode, + startParams, + queryImageUrls: [] + }); + + expect(result).toBe(startParams); + }); +}); diff --git a/packages/service/test/core/workflow/dispatch/dataset/search.test.ts b/packages/service/test/core/workflow/dispatch/dataset/search.test.ts new file mode 100644 index 000000000000..6759beda2caa --- /dev/null +++ b/packages/service/test/core/workflow/dispatch/dataset/search.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DatasetSearchModeEnum } from '@fastgpt/global/core/dataset/constants'; +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; + +const mockDefaultSearchDatasetData = vi.hoisted(() => vi.fn()); +const mockMongoDatasetFindById = vi.hoisted(() => vi.fn()); +const mockUsagePush = vi.hoisted(() => vi.fn()); + +vi.mock('@fastgpt/service/core/dataset/search/controller', () => ({ + defaultSearchDatasetData: mockDefaultSearchDatasetData, + deepRagSearch: vi.fn() +})); + +vi.mock('@fastgpt/service/core/dataset/schema', () => ({ + MongoDataset: { + findById: mockMongoDatasetFindById + } +})); + +vi.mock('@fastgpt/service/core/ai/model', () => ({ + getEmbeddingModel: vi.fn(() => ({ + model: 'mock-embedding-model', + name: 'Mock Embedding Model' + })), + getRerankModel: vi.fn(() => undefined) +})); + +vi.mock('@fastgpt/service/support/wallet/usage/utils', () => ({ + formatModelChars2Points: vi.fn(() => ({ + totalPoints: 0, + modelName: 'Mock Model' + })) +})); + +vi.mock('@fastgpt/service/core/dataset/utils', () => ({ + filterDatasetsByTmbId: vi.fn() +})); + +import { dispatchDatasetSearch } from '@fastgpt/service/core/workflow/dispatch/dataset/search'; + +const createProps = (userChatInput: string | string[]) => + ({ + runningAppInfo: { + teamId: 'team-1' + }, + runningUserInfo: { + tmbId: 'tmb-1' + }, + histories: [], + node: { + name: 'Dataset Search' + }, + params: { + datasets: [ + { + datasetId: 'dataset-1', + name: 'Dataset', + avatar: '' + } + ], + similarity: 0.4, + limit: 5000, + userChatInput, + authTmbId: false, + searchMode: DatasetSearchModeEnum.embedding, + embeddingWeight: 0.5, + usingReRank: false, + rerankModel: '', + rerankWeight: 0.5, + datasetSearchUsingExtensionQuery: false, + datasetSearchExtensionModel: '', + datasetSearchExtensionBg: '', + collectionFilterMatch: '' + }, + usagePush: mockUsagePush + }) as any; + +describe('dispatchDatasetSearch query images', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockMongoDatasetFindById.mockReturnValue({ + lean: vi.fn().mockResolvedValue({ + vectorModel: { + model: 'mock-embedding-model' + }, + vlmModel: '' + }) + }); + + mockDefaultSearchDatasetData.mockResolvedValue({ + searchRes: [], + embeddingTokens: 0, + reRankInputTokens: 0, + usingSimilarityFilter: false, + usingReRank: false, + queryExtensionResult: undefined, + imageCaptionResult: undefined, + deepSearchResult: undefined + }); + }); + + it('should include image inputs in node response queryImages', async () => { + const response = await dispatchDatasetSearch( + createProps(['black high heels', 'temp/team-1/current.png', 'temp/team-1/manual.pdf']) + ); + + expect(response[DispatchNodeResponseKeyEnum.nodeResponse]?.query).toBe('black high heels'); + expect(response[DispatchNodeResponseKeyEnum.nodeResponse]?.queryImages).toEqual([ + { + key: 'temp/team-1/current.png', + name: 'current.png' + } + ]); + expect(response[DispatchNodeResponseKeyEnum.nodeResponse]?.filteredFileCount).toBe(1); + }); + + it('should keep text-only search response unchanged', async () => { + const response = await dispatchDatasetSearch(createProps('black high heels')); + + expect(response[DispatchNodeResponseKeyEnum.nodeResponse]?.query).toBe('black high heels'); + expect(response[DispatchNodeResponseKeyEnum.nodeResponse]?.queryImages).toBeUndefined(); + }); +}); diff --git a/packages/web/components/common/MySelect/index.tsx b/packages/web/components/common/MySelect/index.tsx index 74f5985b9c1b..ed2ef039917e 100644 --- a/packages/web/components/common/MySelect/index.tsx +++ b/packages/web/components/common/MySelect/index.tsx @@ -55,6 +55,8 @@ export type SelectProps = Omit & { customOnOpen?: () => void; customOnClose?: () => void; menuPlacement?: MenuProps['placement']; + itemStyle?: MenuItemProps; + selectedItemStyle?: MenuItemProps; isInvalid?: boolean; isDisabled?: boolean; @@ -88,6 +90,8 @@ const MySelect = ( customOnOpen, customOnClose, menuPlacement, + itemStyle, + selectedItemStyle, isInvalid, isDisabled, ...props @@ -155,11 +159,13 @@ const MySelect = ( ( )} ); - }, [filterList, onClickChange, value]); + }, [filterList, itemStyle, onClickChange, selectedItemStyle, value]); const isSelecting = loading || isLoading; @@ -243,11 +249,13 @@ const MySelect = ( _hover={isInvalid ? { borderColor: 'red.400' } : { borderColor: 'primary.300' }} {...props} > - - + + {isSelecting && } {valueLabel ? ( - <>{valueLabel} + + {valueLabel} + ) : ( <> {isSearch && isOpen ? ( diff --git a/packages/web/i18n/en/account.json b/packages/web/i18n/en/account.json index 0aa99acaab3d..3bbe2a8d943d 100644 --- a/packages/web/i18n/en/account.json +++ b/packages/web/i18n/en/account.json @@ -94,6 +94,7 @@ "model.vision": "Vision model", "model.vision_tag": "Vision", "model.vision_tip": "If the model supports image recognition, turn on this switch.", + "model.embedding_vision_tip": "Enable this when the embedding model can accept image input for image vector indexing and image search.", "model.voices": "voice role", "model.voices_tip": "Configure multiple through an array, for example:\n\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]", "model_provider": "Model Provider", diff --git a/packages/web/i18n/en/account_model.json b/packages/web/i18n/en/account_model.json index 6b0aafa58593..3113d501e26a 100644 --- a/packages/web/i18n/en/account_model.json +++ b/packages/web/i18n/en/account_model.json @@ -81,7 +81,7 @@ "view_chart": "Chart", "view_table": "Table", "vlm_model": "Vlm", - "vlm_model_tip": "Used to generate additional indexing of images in a document in the knowledge base", + "vlm_model_tip": "Automatically labels images in documents and generates text descriptions to assist text retrieval", "volunme_of_failed_calls": "Error amount", "waiting_test": "Waiting for testing" } diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index abdd2cf493ab..85bc21fd1374 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -240,6 +240,8 @@ "core.ai.Prompt": "Prompt", "core.ai.Support tool": "Tool call", "core.ai.model.Dataset Agent Model": "File read model", + "core.ai.model.multimodal": "Multimodal", + "core.ai.model.multimodal_tip": "Multimodal embedding models can generate vectors for images.", "core.ai.model.Vector Model": "Index model", "core.ai.model.doc_index_and_dialog": "Document Index & Dialog Index", "core.app.Api request": "API Request", @@ -458,7 +460,7 @@ "core.dataset.data.Search data placeholder": "Search Related Data", "core.dataset.data.Updated": "Updated", "core.dataset.data.group": " Groups", - "core.dataset.embedding model tip": "The index model can convert natural language into vectors for semantic search.\nNote that different index models cannot be used together. Once an index model is selected, it cannot be changed.", + "core.dataset.embedding model tip": "The index model converts knowledge base content into vectors for semantic search. Note that knowledge bases using different index models cannot be queried together. Switching the index model requires rebuilding all vector indexes, so choose carefully.", "core.dataset.error.Data not found": "Data Not Found or Deleted", "core.dataset.error.Start Sync Failed": "Failed to Start Sync", "core.dataset.error.unExistDataset": "The knowledge base does not exist", @@ -505,6 +507,8 @@ "core.dataset.search.mode.mixedRecall": "Mixed Search", "core.dataset.search.mode.mixedRecall desc": "Use a combination of vector search and full-text search results, sorted using the RRF algorithm.", "core.dataset.search.score.embedding desc": "Get scores by calculating the distance between vectors, ranging from 0 to 1.", + "core.dataset.search.score.imageEmbedding": "Multimodal Image Search", + "core.dataset.search.score.imageEmbedding desc": "Get scores by calculating the distance between image vectors, ranging from 0 to 1.", "core.dataset.search.score.fullText": "Full Text Search", "core.dataset.search.score.fullText desc": "Calculate the score of the same keywords, ranging from 0 to infinity.", "core.dataset.search.score.reRank": "Result Re-rank", @@ -522,7 +526,14 @@ "core.dataset.test.Test Result": "Test Result", "core.dataset.test.Test Text": "Single Text Test", "core.dataset.test.Test Text Placeholder": "Enter the text to be tested", + "core.dataset.test.image_expired": "Image expired", + "core.dataset.test.image_search_disabled_tip": "Configure an image understanding model or multimodal embedding model first.", + "core.dataset.test.image_token": "[Image]", + "core.dataset.test.input_title": "Test input", + "core.dataset.test.max_images_tip": "Up to 10 images are supported", + "core.dataset.test.search_config": "Search configuration", "core.dataset.test.Test params": "Test Parameters", + "core.dataset.test.upload_image": "Upload image", "core.dataset.test.delete test history": "Delete This Test Result", "core.dataset.test.test history": "Test History", "core.dataset.test.test result placeholder": "Test results will be displayed here", diff --git a/packages/web/i18n/en/dataset.json b/packages/web/i18n/en/dataset.json index 951db2be147b..5530bf129759 100644 --- a/packages/web/i18n/en/dataset.json +++ b/packages/web/i18n/en/dataset.json @@ -36,6 +36,8 @@ "common.error.unKnow": "Unknown error", "common_dataset": "General Dataset", "common_dataset_desc": "Building a knowledge base by importing files, web page links, or manual entry", + "create_dataset_title": "Create {{name}}", + "dataset_name_placeholder": "Give the knowledge base a name", "confirm_delete_collection": "Confirm to delete {{num }} files?", "confirm_import_images": "Total {{num}} | Confirm create", "confirm_to_rebuild_embedding_tip": "This will re-vectorize all data in the knowledge base. It may take a while depending on the data volume. Proceed?", @@ -48,6 +50,8 @@ "data_amount": "{{dataAmount}} chunks, {{indexAmount}} indexes", "data_error_amount": "{{errorAmount}} Group training exception", "data_index_image": "Image index", + "data_index_image_embedding": "Multimodal image index", + "delete_data_index_confirm": "Delete this data index? The corresponding vector will also be removed, so search will no longer match this index.", "data_parsing": "Data analysis", "data_uploading": "Data is being uploaded: {{num}}%", "dataset.Chunk_Number": "Block number", @@ -86,8 +90,15 @@ "file_model_function_tip": "Used for QA generation, auto-indexing, and other AI-powered data processing.", "filename": "Filename", "folder_dataset": "Folder", + "generate_index": "Generate index", "image_auto_parse": "Automatic image indexing", "image_auto_parse_tips": "Call VLM to automatically label the pictures in the document and generate additional search indexes", + "image_auto_parse_tip_commercial": "Upgrade to the commercial edition to use this feature", + "image_auto_parse_tip_multimodal_with_vlm": "Generate image vector indexes and text description indexes for document images to support image search", + "image_auto_parse_tip_multimodal_without_vlm": "Use a multimodal model to generate image vector indexes and support image search", + "image_auto_parse_tip_vlm_only": "Use VLM to automatically label document images and generate text description indexes", + "image_auto_parse_tip_no_vlm_or_multimodal": "Configure an image understanding model or switch to a multimodal vector model before enabling this", + "image_embedding_index_default_desc": "An image vector has been generated by the multimodal model and can be used for image search", "images_creating": "Creating", "immediate_sync": "Immediate Synchronization", "import_confirm": "Start import", @@ -186,6 +197,7 @@ "uploading_progress": "Uploading: {{num}}%", "vector_model_max_tokens_tip": "Each chunk of data has a maximum length of 3000 tokens", "vllm_model": "Image understanding model", + "vllm_model_tip": "Automatically labels images in documents and generates text descriptions to assist text retrieval", "website_dataset": "Web sync", "website_dataset_desc": "Build knowledge base by crawling web page data in batches", "website_info": "Website Information", diff --git a/packages/web/i18n/en/file.json b/packages/web/i18n/en/file.json index 785fbe032970..370104d8fa7b 100644 --- a/packages/web/i18n/en/file.json +++ b/packages/web/i18n/en/file.json @@ -1,6 +1,6 @@ { "Image_Preview": "Picture preview", - "Image_dataset_requires_VLM_model_to_be_configured": "The image dataset needs to be configured with the image understanding model (VLM) to be used. Please add a model that supports image understanding in the model configuration first.", + "Image_dataset_requires_VLM_model_to_be_configured": "Image datasets require an image understanding model or a multimodal index model. Please configure a matching model first.", "click_to_view_raw_source": "Click to View Original Source", "file_name": "Filename", "file_size": "Filesize", diff --git a/packages/web/i18n/zh-CN/account.json b/packages/web/i18n/zh-CN/account.json index 67465edf5027..0b90329c2f44 100644 --- a/packages/web/i18n/zh-CN/account.json +++ b/packages/web/i18n/zh-CN/account.json @@ -94,6 +94,7 @@ "model.vision": "支持图片识别", "model.vision_tag": "视觉", "model.vision_tip": "如果模型支持图片识别,则打开该开关。", + "model.embedding_vision_tip": "开启后该索引模型可接收图片输入,并用于图片向量索引和图搜图。", "model.voices": "声音角色", "model.voices_tip": "通过一个数组配置多个,例如:\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]", "model_provider": "模型提供商", diff --git a/packages/web/i18n/zh-CN/account_model.json b/packages/web/i18n/zh-CN/account_model.json index 01e92ed0ef1b..ad9e044246cc 100644 --- a/packages/web/i18n/zh-CN/account_model.json +++ b/packages/web/i18n/zh-CN/account_model.json @@ -81,7 +81,7 @@ "view_chart": "图表", "view_table": "表格", "vlm_model": "图片理解模型", - "vlm_model_tip": "用于知识库中对文档中的图片进行额外的索引生成", + "vlm_model_tip": "自动标注文档里的图片并生成文本描述,辅助文本检索", "volunme_of_failed_calls": "调用失败量", "waiting_test": "等待测试" } diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 5da10d595d77..463a198b61ad 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -240,6 +240,8 @@ "core.ai.Prompt": "提示词", "core.ai.Support tool": "工具调用", "core.ai.model.Dataset Agent Model": "文本理解模型", + "core.ai.model.multimodal": "多模态", + "core.ai.model.multimodal_tip": "多模态索引模型可以给图片生成向量。", "core.ai.model.Vector Model": "索引模型", "core.ai.model.doc_index_and_dialog": "文档索引 & 对话索引", "core.app.Api request": "API 访问", @@ -458,7 +460,7 @@ "core.dataset.data.Search data placeholder": "搜索相关数据", "core.dataset.data.Updated": "已更新", "core.dataset.data.group": "组", - "core.dataset.embedding model tip": "索引模型可以将自然语言转成向量,用于进行语义检索。\n注意,不同索引模型无法一起使用,选择完索引模型后将无法修改。", + "core.dataset.embedding model tip": "索引模型可以将知识库内容转成向量,用于进行语义检索。注意,不同索引模型的知识库无法同时查询,切换索引模型需重建全量向量索引,请慎重选择。", "core.dataset.error.Data not found": "数据不存在或已被删除", "core.dataset.error.Start Sync Failed": "开始同步失败", "core.dataset.error.unExistDataset": "知识库不存在", @@ -505,6 +507,8 @@ "core.dataset.search.mode.mixedRecall": "混合检索", "core.dataset.search.mode.mixedRecall desc": "使用向量检索与全文检索的综合结果返回,使用 RRF 算法进行排序。", "core.dataset.search.score.embedding desc": "通过计算向量之间的距离获取得分,范围为 0~1。", + "core.dataset.search.score.imageEmbedding": "多模态图片检索", + "core.dataset.search.score.imageEmbedding desc": "通过计算图片向量之间的距离获取得分,范围为 0~1。", "core.dataset.search.score.fullText": "全文检索", "core.dataset.search.score.fullText desc": "计算相同关键词的得分,范围为 0~无穷。", "core.dataset.search.score.reRank": "结果重排", @@ -521,8 +525,15 @@ "core.dataset.test.Test": "测试", "core.dataset.test.Test Result": "测试结果", "core.dataset.test.Test Text": "单个文本测试", - "core.dataset.test.Test Text Placeholder": "输入需要测试的文本", + "core.dataset.test.Test Text Placeholder": "输入需要测试的内容", + "core.dataset.test.image_expired": "图片已过期", + "core.dataset.test.image_search_disabled_tip": "请配置图片理解模型或多模态索引模型", + "core.dataset.test.image_token": "[图片]", + "core.dataset.test.input_title": "输入测试内容", + "core.dataset.test.max_images_tip": "最多支持上传10张图片", + "core.dataset.test.search_config": "搜索配置", "core.dataset.test.Test params": "测试参数", + "core.dataset.test.upload_image": "上传图片", "core.dataset.test.delete test history": "删除该测试结果", "core.dataset.test.test history": "测试历史", "core.dataset.test.test result placeholder": "测试结果将在这里展示", diff --git a/packages/web/i18n/zh-CN/dataset.json b/packages/web/i18n/zh-CN/dataset.json index 60162f996195..1669734dcbb2 100644 --- a/packages/web/i18n/zh-CN/dataset.json +++ b/packages/web/i18n/zh-CN/dataset.json @@ -36,6 +36,8 @@ "common.error.unKnow": "未知错误", "common_dataset": "通用知识库", "common_dataset_desc": "通过导入文件、网页链接或手动录入形式构建知识库", + "create_dataset_title": "创建{{name}}", + "dataset_name_placeholder": "给知识库取一个名字", "confirm_delete_collection": "确认删除 {{num }} 个文件?", "confirm_import_images": "共 {{num}} 张图片 | 确认创建", "confirm_to_rebuild_embedding_tip": "确认为知识库切换索引?\n切换索引是一个非常重量的操作,需要对您知识库内所有数据进行重新索引,时间可能较长,请确保账号内剩余积分充足。\n\n此外,你还需要注意修改选择该知识库的应用,避免它们与其他索引模型知识库混用。", @@ -48,6 +50,8 @@ "data_amount": "{{dataAmount}} 组数据, {{indexAmount}} 组索引", "data_error_amount": "{{errorAmount}} 组训练异常", "data_index_image": "图片索引", + "data_index_image_embedding": "多模态图片索引", + "delete_data_index_confirm": "确认删除该数据索引?删除后会同步清理对应向量,搜索将不再命中该索引。", "data_parsing": "数据解析中", "data_uploading": "数据上传中: {{num}}%", "dataset.Chunk_Number": "分块号", @@ -86,8 +90,15 @@ "file_model_function_tip": "用于增强索引和 QA 生成", "filename": "文件名", "folder_dataset": "文件夹", + "generate_index": "生成索引", "image_auto_parse": "图片自动索引", "image_auto_parse_tips": "调用 VLM 自动标注文档里的图片,并生成额外的检索索引", + "image_auto_parse_tip_commercial": "请升级商业版后使用该功能", + "image_auto_parse_tip_multimodal_with_vlm": "为文档中的图片生成图片向量索引和文本描述索引,支持以图搜图", + "image_auto_parse_tip_multimodal_without_vlm": "使用多模态模型为图片生成向量索引,支持以图搜图", + "image_auto_parse_tip_vlm_only": "调用 VLM 自动标注文档里的图片,并生成文本描述索引", + "image_auto_parse_tip_no_vlm_or_multimodal": "需配置图片理解模型,或切换多模态向量模型后,方可启用", + "image_embedding_index_default_desc": "已通过多模态模型生成图片向量,支持以图搜图", "images_creating": "正在创建", "immediate_sync": "立即同步", "import_confirm": "确认上传", @@ -186,6 +197,7 @@ "uploading_progress": "上传中: {{num}}%", "vector_model_max_tokens_tip": "每个分块数据,最大长度为 3000 tokens", "vllm_model": "图片理解模型", + "vllm_model_tip": "自动标注文档里的图片并生成文本描述,辅助文本检索", "website_dataset": "Web 站点同步", "website_dataset_desc": "通过爬虫,批量爬取网页数据构建知识库", "website_info": "网站信息", diff --git a/packages/web/i18n/zh-CN/file.json b/packages/web/i18n/zh-CN/file.json index d4ea9b0c44bd..130cb217c50c 100644 --- a/packages/web/i18n/zh-CN/file.json +++ b/packages/web/i18n/zh-CN/file.json @@ -1,6 +1,6 @@ { "Image_Preview": "图片预览", - "Image_dataset_requires_VLM_model_to_be_configured": "图片数据集需要配置图片理解模型(VLM)才能使用,请先在模型配置中添加支持图片理解的模型", + "Image_dataset_requires_VLM_model_to_be_configured": "图片数据集需要配置图片理解模型或多模态索引模型才能使用,请先在模型配置中添加对应模型", "click_to_view_raw_source": "点击查看来源", "file_name": "文件名", "file_size": "文件大小", diff --git a/packages/web/i18n/zh-Hant/account.json b/packages/web/i18n/zh-Hant/account.json index c4decbe4c8c2..db3d176cbee9 100644 --- a/packages/web/i18n/zh-Hant/account.json +++ b/packages/web/i18n/zh-Hant/account.json @@ -94,6 +94,7 @@ "model.vision": "支援圖片識別", "model.vision_tag": "視覺", "model.vision_tip": "如果模型支援圖片識別,則開啟該開關。", + "model.embedding_vision_tip": "開啟後該索引模型可接收圖片輸入,並用於圖片向量索引和圖搜圖。", "model.voices": "聲音角色", "model.voices_tip": "透過一個陣列設定多個,例如:\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]", "model_provider": "模型提供者", diff --git a/packages/web/i18n/zh-Hant/account_model.json b/packages/web/i18n/zh-Hant/account_model.json index 8778ff328e68..739030b3b07f 100644 --- a/packages/web/i18n/zh-Hant/account_model.json +++ b/packages/web/i18n/zh-Hant/account_model.json @@ -81,7 +81,7 @@ "view_chart": "圖表", "view_table": "表格", "vlm_model": "圖片理解模型", - "vlm_model_tip": "用於知識庫中對文件中的圖片進行額外的索引生成", + "vlm_model_tip": "自動標註文件裡的圖片並生成文字描述,輔助文字檢索", "volunme_of_failed_calls": "調用失敗量", "waiting_test": "等待測試" } diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 0b02196e16c1..5ea0cc98d2bf 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -237,6 +237,8 @@ "core.ai.Prompt": "提示詞", "core.ai.Support tool": "工具調用", "core.ai.model.Dataset Agent Model": "檔案處理模型", + "core.ai.model.multimodal": "多模態", + "core.ai.model.multimodal_tip": "多模態索引模型可以給圖片生成向量。", "core.ai.model.Vector Model": "索引模型", "core.ai.model.doc_index_and_dialog": "文件索引與對話索引", "core.app.Api request": "API 存取", @@ -453,7 +455,7 @@ "core.dataset.data.Search data placeholder": "搜尋相關資料", "core.dataset.data.Updated": "已更新", "core.dataset.data.group": "組", - "core.dataset.embedding model tip": "索引模型可以將自然語言轉換成向量,用於進行語意搜尋。\n注意,不同索引模型無法一起使用。選擇索引模型後就無法修改。", + "core.dataset.embedding model tip": "索引模型可以將知識庫內容轉成向量,用於進行語意檢索。注意,不同索引模型的知識庫無法同時查詢,切換索引模型需重建全量向量索引,請慎重選擇。", "core.dataset.error.Data not found": "資料不存在或已被刪除", "core.dataset.error.Start Sync Failed": "開始同步失敗", "core.dataset.error.unExistDataset": "知識庫不存在", @@ -500,6 +502,8 @@ "core.dataset.search.mode.mixedRecall": "混合檢索", "core.dataset.search.mode.mixedRecall desc": "使用向量檢索與全文檢索的綜合結果,並使用 RRF 演算法進行排序。", "core.dataset.search.score.embedding desc": "透過計算向量之間的距離取得分數,範圍為 0 到 1。", + "core.dataset.search.score.imageEmbedding": "多模態圖片檢索", + "core.dataset.search.score.imageEmbedding desc": "透過計算圖片向量之間的距離取得分數,範圍為 0 到 1。", "core.dataset.search.score.fullText": "全文檢索", "core.dataset.search.score.fullText desc": "計算相同關鍵字的分數,範圍為 0 到無限大。", "core.dataset.search.score.reRank": "結果重新排名", @@ -516,8 +520,15 @@ "core.dataset.test.Test": "測試", "core.dataset.test.Test Result": "測試結果", "core.dataset.test.Test Text": "單一文字測試", - "core.dataset.test.Test Text Placeholder": "輸入需要測試的文字", + "core.dataset.test.Test Text Placeholder": "輸入需要測試的內容", + "core.dataset.test.image_expired": "圖片已過期", + "core.dataset.test.image_search_disabled_tip": "請配置圖片理解模型或多模態索引模型", + "core.dataset.test.image_token": "[圖片]", + "core.dataset.test.input_title": "輸入測試內容", + "core.dataset.test.max_images_tip": "最多支援上傳10張圖片", + "core.dataset.test.search_config": "搜尋設定", "core.dataset.test.Test params": "測試參數", + "core.dataset.test.upload_image": "上傳圖片", "core.dataset.test.delete test history": "刪除此測試結果", "core.dataset.test.test history": "測試歷史", "core.dataset.test.test result placeholder": "測試結果將顯示在這裡", diff --git a/packages/web/i18n/zh-Hant/dataset.json b/packages/web/i18n/zh-Hant/dataset.json index 9ad8be397bf0..690b403221d1 100644 --- a/packages/web/i18n/zh-Hant/dataset.json +++ b/packages/web/i18n/zh-Hant/dataset.json @@ -36,6 +36,8 @@ "common.error.unKnow": "未知錯誤", "common_dataset": "通用資料集", "common_dataset_desc": "通過導入文件、網頁鏈接或手動錄入形式構建知識庫", + "create_dataset_title": "建立{{name}}", + "dataset_name_placeholder": "給知識庫取一個名字", "confirm_delete_collection": "確認刪除 {{num }} 個文件?", "confirm_import_images": "共 {{num}} 張圖片 | 確認創建", "confirm_to_rebuild_embedding_tip": "確定要為資料集切換索引嗎?\n切換索引是一個重要的操作,需要對您資料集內所有資料重新建立索引,可能需要較長時間,請確保帳號內剩餘點數充足。\n\n此外,您還需要注意修改使用此資料集的應用程式,避免與其他索引模型資料集混用。", @@ -48,6 +50,8 @@ "data_amount": "{{dataAmount}} 組資料,{{indexAmount}} 組索引", "data_error_amount": "{{errorAmount}} 組訓練異常", "data_index_image": "圖片索引", + "data_index_image_embedding": "多模態圖片索引", + "delete_data_index_confirm": "確認刪除此資料索引?刪除後會同步清理對應向量,搜尋將不再命中該索引。", "data_parsing": "數據解析中", "data_uploading": "數據上傳中: {{num}}%", "dataset.Chunk_Number": "分塊號", @@ -86,8 +90,15 @@ "file_model_function_tip": "用於增強索引和問答生成", "filename": "檔案名稱", "folder_dataset": "資料夾", + "generate_index": "生成索引", "image_auto_parse": "圖片自動索引", "image_auto_parse_tips": "呼叫 VLM 自動標註文件裡的圖片,並生成額外的檢索索引", + "image_auto_parse_tip_commercial": "請升級商業版後使用該功能", + "image_auto_parse_tip_multimodal_with_vlm": "為文件中的圖片生成圖片向量索引和文字描述索引,支援以圖搜圖", + "image_auto_parse_tip_multimodal_without_vlm": "使用多模態模型為圖片生成向量索引,支援以圖搜圖", + "image_auto_parse_tip_vlm_only": "呼叫 VLM 自動標註文件裡的圖片,並生成文字描述索引", + "image_auto_parse_tip_no_vlm_or_multimodal": "需設定圖片理解模型,或切換多模態向量模型後,方可啟用", + "image_embedding_index_default_desc": "已透過多模態模型生成圖片向量,支援以圖搜圖", "images_creating": "正在創建", "immediate_sync": "立即同步", "import_confirm": "確認上傳", @@ -186,6 +197,7 @@ "uploading_progress": "上傳中: {{num}}%", "vector_model_max_tokens_tip": "每個分塊資料,最大長度為 3000 tokens", "vllm_model": "圖片理解模型", + "vllm_model_tip": "自動標註文件裡的圖片並生成文字描述,輔助文字檢索", "website_dataset": "網站同步", "website_dataset_desc": "通過爬蟲,批量爬取網頁數據構建知識庫", "website_info": "網站資訊", diff --git a/packages/web/i18n/zh-Hant/file.json b/packages/web/i18n/zh-Hant/file.json index b625c6ad57f3..84751bbdf09c 100644 --- a/packages/web/i18n/zh-Hant/file.json +++ b/packages/web/i18n/zh-Hant/file.json @@ -1,6 +1,6 @@ { "Image_Preview": "圖片預覽", - "Image_dataset_requires_VLM_model_to_be_configured": "圖片數據集需要配置圖片理解模型(VLM)才能使用,請先在模型配置中添加支持圖片理解的模型", + "Image_dataset_requires_VLM_model_to_be_configured": "圖片數據集需要設定圖片理解模型或多模態索引模型才能使用,請先在模型設定中新增對應模型", "click_to_view_raw_source": "點選檢視原始來源", "file_name": "檔案名稱", "file_size": "檔案大小", diff --git a/projects/app/src/components/Select/AIModelSelector.tsx b/projects/app/src/components/Select/AIModelSelector.tsx index 822dd6e5e28c..dc4268f45f8e 100644 --- a/projects/app/src/components/Select/AIModelSelector.tsx +++ b/projects/app/src/components/Select/AIModelSelector.tsx @@ -11,6 +11,7 @@ import TestModeBetaTag from '@/components/core/ai/TestModeBetaTag'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useTranslation } from 'next-i18next'; import React, { useCallback, useMemo, useState } from 'react'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; type Props = SelectProps & { disableTip?: string; @@ -21,33 +22,78 @@ type Props = SelectProps & { const isTestModeModel = (model?: SystemModelItemType) => { return !!model?.testMode; }; +const isMultimodalEmbeddingModel = (model?: SystemModelItemType) => { + return model?.type === ModelTypeEnum.embedding && !!model.vision; +}; -const SelectorActiveTestModeTip = React.memo(function SelectorActiveTestModeTip() { - return ( - - - - ); -}); +const multimodalTagStyles = { + display: 'inline-flex', + px: '8px', + py: '4px', + justifyContent: 'center', + alignItems: 'center', + gap: '6px', + borderRadius: '6px', + bg: '#F0FBFF', + color: '#005B9C', + fontFamily: 'PingFang SC', + fontSize: '10px', + fontStyle: 'normal', + fontWeight: 500, + lineHeight: '14px', + letterSpacing: '0.2px', + flexShrink: 0 +} as const; + +const modelOptionRowStyles = { + w: '320px', + h: '45px', + px: '12px', + py: '6px', + justifyContent: 'flex-start', + alignItems: 'center', + alignSelf: 'stretch', + gap: '10px', + borderRadius: '4px' +} as const; + +const modelNameTextStyles = { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' +} as const; const ModelOptionLabel = React.memo(function ModelOptionLabel({ name, showTestModeTip, + showMultimodalTip, noOfLines }: { name: string; showTestModeTip: boolean; + showMultimodalTip?: boolean; noOfLines?: ResponsiveValue; }) { + const { t } = useTranslation(); + return ( - - + + {name} - {showTestModeTip && ( - - - + {(showTestModeTip || showMultimodalTip) && ( + + {showTestModeTip && ( + + + + )} + {showMultimodalTip && ( + + {t('common:core.ai.model.multimodal')} + + )} + )} ); @@ -123,10 +169,10 @@ const OneRowSelector = ({ return { value: item.value, label: ( - + ) @@ -160,10 +207,23 @@ const OneRowSelector = ({ className="nowheel" isDisabled={!!disableTip} list={avatarList} + itemStyle={{ + p: 0, + mb: 0.5, + borderRadius: '4px', + _hover: { + bg: 'rgba(17, 24, 36, 0.05)' + } + }} + selectedItemStyle={{ + color: 'primary.700', + bg: 'rgba(17, 24, 36, 0.05)' + }} valueLabel={ selectedModelData ? ( - + ) : undefined @@ -181,13 +242,19 @@ const OneRowSelector = ({ placeholder={loading ? t('common:model_loading') : t('common:not_model_config')} h={'40px'} whiteSpace={'nowrap'} + sx={{ + '& > div, & > div > div': { + flex: '1 1 auto', + minWidth: 0, + overflow: 'hidden' + } + }} {...props} onChange={(e) => { return onChange?.(e); }} /> - {isTestModeModel(selectedModelData) && } ); }; @@ -249,11 +316,6 @@ const MultipleRowSelector = ({ //@ts-ignore return props.size ? size[props.size] : size['md']; }, [props.size]); - const selectedModelData = useMemo( - () => modelList.find((model) => model?.model === props.value), - [modelList, props.value] - ); - const selectorList = useMemo(() => { const renderList = getModelProviders(i18n.language).map<{ label: React.JSX.Element; @@ -263,6 +325,7 @@ const MultipleRowSelector = ({ label: ( + + + ), value: modelData.model }); @@ -313,15 +382,21 @@ const MultipleRowSelector = ({ const avatar = getModelProvider(modelData.provider)?.avatar; return ( - + - + ); }, [loading, props.value, t, modelList, getModelProvider, avatarSize, noOfLines]); @@ -351,7 +426,6 @@ const MultipleRowSelector = ({ }} /> - {isTestModeModel(selectedModelData) && } ); }; diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/ChatInput.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/ChatInput.tsx index 53f923479ab2..85871d91e4be 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/ChatInput.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/Input/ChatInput.tsx @@ -146,13 +146,14 @@ const ChatInput = ({ const RenderTextarea = useMemo( () => ( - 0 ? 1 : 0}> + {/* Textarea */} {/* Prompt Container */}