diff --git a/app/Console/Commands/GenerateManticoreVectors.php b/app/Console/Commands/GenerateManticoreVectors.php deleted file mode 100644 index ba0a08f6f..000000000 --- a/app/Console/Commands/GenerateManticoreVectors.php +++ /dev/null @@ -1,205 +0,0 @@ - [ - 'syncKey' => 'sync:manticoreMsgLastId', - 'vectorKey' => 'vector:manticoreMsgLastId', - 'class' => ManticoreMsg::class, - 'model' => WebSocketDialogMsg::class, - 'idField' => 'id', - ], - 'file' => [ - 'syncKey' => 'sync:manticoreFileLastId', - 'vectorKey' => 'vector:manticoreFileLastId', - 'class' => ManticoreFile::class, - 'model' => File::class, - 'idField' => 'id', - ], - 'task' => [ - 'syncKey' => 'sync:manticoreTaskLastId', - 'vectorKey' => 'vector:manticoreTaskLastId', - 'class' => ManticoreTask::class, - 'model' => ProjectTask::class, - 'idField' => 'id', - ], - 'project' => [ - 'syncKey' => 'sync:manticoreProjectLastId', - 'vectorKey' => 'vector:manticoreProjectLastId', - 'class' => ManticoreProject::class, - 'model' => Project::class, - 'idField' => 'id', - ], - 'user' => [ - 'syncKey' => 'sync:manticoreUserLastId', - 'vectorKey' => 'vector:manticoreUserLastId', - 'class' => ManticoreUser::class, - 'model' => User::class, - 'idField' => 'userid', - ], - ]; - - public function handle(): int - { - if (!Apps::isInstalled("search")) { - $this->error("应用「Manticore Search」未安装"); - return 1; - } - - if (!Apps::isInstalled("ai")) { - $this->error("应用「AI」未安装,无法生成向量"); - return 1; - } - - $this->registerSignalHandlers(); - - if (!$this->acquireLock()) { - return 1; - } - - $type = $this->option('type'); - $batchSize = intval($this->option('batch')); - $sleepSeconds = intval($this->option('sleep')); - $reset = $this->option('reset'); - - if ($type === 'all') { - $types = array_keys(self::TYPE_CONFIG); - } else { - if (!isset(self::TYPE_CONFIG[$type])) { - $this->error("未知类型: {$type}。可用类型: msg, file, task, project, user, all"); - $this->releaseLock(); - return 1; - } - $types = [$type]; - } - - // 持续处理直到所有类型都没有待处理数据 - $round = 0; - do { - $round++; - $totalPending = 0; - - foreach ($types as $t) { - if ($this->shouldStop) { - break; - } - $pending = $this->processType($t, $batchSize, $reset && $round === 1); - $totalPending += $pending; - } - - // 如果还有待处理数据,休眠后继续 - if ($totalPending > 0 && !$this->shouldStop) { - $this->info("\n--- 第 {$round} 轮完成,剩余 {$totalPending} 条待处理,{$sleepSeconds} 秒后继续 ---\n"); - sleep($sleepSeconds); - $this->setLock(); // 刷新锁 - } - } while ($totalPending > 0 && !$this->shouldStop); - - $this->info("\n向量生成完成(共 {$round} 轮)"); - $this->releaseLock(); - return 0; - } - - /** - * 处理单个类型的向量生成(每次处理一批) - * - * @param string $type 类型 - * @param int $batchSize 每批数量 - * @param bool $reset 是否重置进度 - * @return int 剩余待处理数量 - */ - private function processType(string $type, int $batchSize, bool $reset): int - { - $config = self::TYPE_CONFIG[$type]; - - // 获取进度指针 - $syncLastId = intval(ManticoreKeyValue::get($config['syncKey'], 0)); - $vectorLastId = $reset ? 0 : intval(ManticoreKeyValue::get($config['vectorKey'], 0)); - - if ($reset) { - ManticoreKeyValue::set($config['vectorKey'], 0); - $this->info("[{$type}] 已重置向量进度指针"); - } - - // 计算待处理范围 - $pendingCount = $syncLastId - $vectorLastId; - if ($pendingCount <= 0) { - return 0; - } - - // 获取待处理的 ID 列表(每次处理 batchSize * 5 条,让 generateVectorsBatch 内部再分批调用 API) - $modelClass = $config['model']; - $idField = $config['idField']; - $fetchCount = $batchSize * 5; - - $ids = $modelClass::where($idField, '>', $vectorLastId) - ->where($idField, '<=', $syncLastId) - ->orderBy($idField) - ->limit($fetchCount) - ->pluck($idField) - ->toArray(); - - if (empty($ids)) { - return 0; - } - - // 批量生成向量 - $manticoreClass = $config['class']; - $successCount = $manticoreClass::generateVectorsBatch($ids, $batchSize); - - $currentLastId = end($ids); - - // 更新向量进度指针 - ManticoreKeyValue::set($config['vectorKey'], $currentLastId); - - $remaining = $pendingCount - count($ids); - $this->info("[{$type}] 处理 " . count($ids) . " 条,成功 {$successCount},ID: {$vectorLastId} -> {$currentLastId},剩余 {$remaining}"); - - // 刷新锁 - $this->setLock(); - - return max(0, $remaining); - } -} diff --git a/app/Models/ManticoreSyncFailure.php b/app/Models/ManticoreSyncFailure.php index 0c9ccc9a4..ecf9c6e37 100644 --- a/app/Models/ManticoreSyncFailure.php +++ b/app/Models/ManticoreSyncFailure.php @@ -90,6 +90,24 @@ class ManticoreSyncFailure extends AbstractModel ->delete(); } + /** + * 批量清除同步成功记录(供批量写入路径使用,避免逐条删除) + * + * @param string $dataType 数据类型 + * @param array $dataIds 数据ID列表 + * @param string $action 操作类型 + */ + public static function removeSuccessBatch(string $dataType, array $dataIds, string $action): void + { + if (empty($dataIds)) { + return; + } + self::where('data_type', $dataType) + ->whereIn('data_id', $dataIds) + ->where('action', $action) + ->delete(); + } + /** * 获取待重试的记录 * 根据重试次数决定间隔:1次=1分钟,2次=5分钟,3次=15分钟,4次+=30分钟 diff --git a/app/Module/AI.php b/app/Module/AI.php index d03445b3f..10e91cbd3 100644 --- a/app/Module/AI.php +++ b/app/Module/AI.php @@ -900,7 +900,61 @@ class AI } /** - * 通过 OpenAI 兼容接口获取文本的 Embedding 向量 + * 调用 ai 插件的 /embeddings 端点批量向量化(免费向量模型,零配置)。 + * + * 走主程序 ↔ ai 插件的内网调用,共享主程序 APP_KEY 鉴权; + * 向量维度由 ai 插件的模型决定,主程序不再传 dimensions。 + * + * @param array $texts 文本数组 + * @return array retSuccess(data=向量数组的数组,与输入同序,缺失位置为 []) / retError + */ + protected static function requestPluginEmbeddings(array $texts) + { + $texts = array_values($texts); + $count = count($texts); + if ($count === 0) { + return Base::retSuccess("success", []); + } + + $host = config('dootask.ai_host', 'ai'); + $port = (int) config('dootask.ai_port', 5001); + $url = "http://{$host}:{$port}/embeddings"; + + $post = json_encode(["input" => $texts]); + $headers = [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . (string) config('app.key'), + ]; + $timeout = $count > 1 ? 120 : 30; + + $res = Ihttp::ihttp_request($url, $post, $headers, $timeout); + if (Base::isError($res)) { + return Base::retError("Embedding 接口请求失败", $res); + } + + $resData = Base::json2array($res['data']); + if (empty($resData['data']) || !is_array($resData['data'])) { + return Base::retError("Embedding 接口返回数据格式错误", $resData); + } + + // 按 index 回填,保证与输入顺序对齐,缺失位置留 [] + $vectors = array_fill(0, $count, []); + foreach ($resData['data'] as $item) { + $idx = $item['index'] ?? null; + if ($idx === null || !isset($vectors[$idx])) { + continue; + } + $embedding = $item['embedding'] ?? []; + if (is_array($embedding) && !empty($embedding)) { + $vectors[$idx] = $embedding; + } + } + + return Base::retSuccess("success", $vectors); + } + + /** + * 通过 ai 插件的免费向量模型获取文本的 Embedding 向量 * * @param string $text 需要转换的文本 * @param bool $noCache 是否禁用缓存 @@ -916,50 +970,22 @@ class AI return Base::retError('文本内容不能为空'); } - // 截断过长的文本(OpenAI 限制 8191 tokens,约 32K 字符) + // 截断过长的文本(约 32K 字符) $text = mb_substr($text, 0, 30000); - $cacheKey = "openAIEmbedding::" . md5($text); + // 缓存键换命名空间(embeddingV2):避免历史 1536 维缓存污染新的向量维度 + $cacheKey = "embeddingV2::" . md5($text); if ($noCache) { Cache::forget($cacheKey); } - $provider = self::resolveEmbeddingProvider(); - if (!$provider) { - return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务"); - } - - $result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $provider) { - $payload = [ - "model" => $provider['model'], - "input" => $text, - ]; - - // 统一向量维度为 1536(与 Manticore 配置一致) - // OpenAI、智谱等支持 dimensions 参数的厂商需要显式指定 - $supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']); - if ($supportsDimensions) { - $payload['dimensions'] = 1536; - } - - $post = json_encode($payload); - - $ai = new self($post); - $ai->setProvider($provider); - $ai->setUrlPath('/embeddings'); - $ai->setTimeout(30); - - $res = $ai->request(true); + $result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text) { + $res = self::requestPluginEmbeddings([$text]); if (Base::isError($res)) { - return Base::retError("Embedding 请求失败", $res); + return $res; } - $resData = Base::json2array($res['data']); - if (empty($resData['data'][0]['embedding'])) { - return Base::retError("Embedding 接口返回数据格式错误", $resData); - } - - $embedding = $resData['data'][0]['embedding']; + $embedding = $res['data'][0] ?? []; if (!is_array($embedding) || empty($embedding)) { return Base::retError("Embedding 向量为空"); } @@ -973,193 +999,4 @@ class AI return $result; } - - /** - * 批量获取文本的 Embedding 向量 - * OpenAI API 原生支持批量输入,一次请求处理多个文本 - * - * @param array $texts 文本数组(最多 100 条) - * @param bool $noCache 是否禁用缓存 - * @return array 返回结果,成功时 data 为向量数组的数组(与输入顺序对应) - */ - public static function getBatchEmbeddings(array $texts, $noCache = false) - { - if (!Apps::isInstalled('ai')) { - return Base::retError('应用「AI Assistant」未安装'); - } - - if (empty($texts)) { - return Base::retSuccess("success", []); - } - - // 限制批量大小 - // OpenAI 限制:最多 2048 条,单次请求合计最多 300,000 tokens - // 这里限制 500 条,假设平均每条 500 tokens,合计 250,000 tokens - $texts = array_slice($texts, 0, 500); - - // 准备结果数组,并检查缓存 - $results = []; - $uncachedTexts = []; - $uncachedIndices = []; - - foreach ($texts as $index => $text) { - if (empty($text)) { - $results[$index] = []; - continue; - } - - // 截断过长的文本 - $text = mb_substr($text, 0, 30000); - $texts[$index] = $text; // 更新截断后的文本 - - $cacheKey = "openAIEmbedding::" . md5($text); - - if ($noCache) { - Cache::forget($cacheKey); - } - - // 检查缓存 - if (!$noCache && Cache::has($cacheKey)) { - $cached = Cache::get($cacheKey); - if (Base::isSuccess($cached)) { - $results[$index] = $cached['data']; - continue; - } - } - - // 未命中缓存,加入待请求列表 - $uncachedTexts[] = $text; - $uncachedIndices[] = $index; - } - - // 如果所有文本都在缓存中 - if (empty($uncachedTexts)) { - // 按原始顺序返回 - ksort($results); - return Base::retSuccess("success", array_values($results)); - } - - // 获取 provider - $provider = self::resolveEmbeddingProvider(); - if (!$provider) { - return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务"); - } - - // 构建批量请求 - $payload = [ - "model" => $provider['model'], - "input" => $uncachedTexts, - ]; - - $supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']); - if ($supportsDimensions) { - $payload['dimensions'] = 1536; - } - - $post = json_encode($payload); - - $ai = new self($post); - $ai->setProvider($provider); - $ai->setUrlPath('/embeddings'); - $ai->setTimeout(120); // 批量请求需要更长超时 - - $res = $ai->request(true); - if (Base::isError($res)) { - return Base::retError("批量 Embedding 请求失败", $res); - } - - $resData = Base::json2array($res['data']); - if (empty($resData['data'])) { - return Base::retError("Embedding 接口返回数据格式错误", $resData); - } - - // 处理返回的向量并写入缓存 - foreach ($resData['data'] as $item) { - $itemIndex = $item['index'] ?? null; - if ($itemIndex === null || !isset($uncachedIndices[$itemIndex])) { - continue; - } - - $originalIndex = $uncachedIndices[$itemIndex]; - $embedding = $item['embedding'] ?? []; - - if (!empty($embedding) && is_array($embedding)) { - $results[$originalIndex] = $embedding; - } else { - $results[$originalIndex] = []; - } - } - - // 填充未获取到向量的位置 - foreach ($uncachedIndices as $originalIndex) { - if (!isset($results[$originalIndex])) { - $results[$originalIndex] = []; - } - } - - // 按原始顺序返回 - ksort($results); - return Base::retSuccess("success", array_values($results)); - } - - /** - * 获取 Embedding 模型配置 - * - * @return array|null - */ - protected static function resolveEmbeddingProvider() - { - $setting = Base::setting('aibotSetting'); - if (!is_array($setting)) { - $setting = []; - } - - // 优先使用 OpenAI(支持 embedding 接口) - $key = trim((string)($setting['openai_key'] ?? '')); - if ($key !== '') { - $baseUrl = trim((string)($setting['openai_base_url'] ?? '')); - $baseUrl = $baseUrl ?: 'https://api.openai.com/v1'; - $agency = trim((string)($setting['openai_agency'] ?? '')); - - return [ - 'vendor' => 'openai', - 'model' => 'text-embedding-3-small', - 'api_key' => $key, - 'base_url' => rtrim($baseUrl, '/'), - 'agency' => $agency, - ]; - } - - $vendorDefaults = [ - 'deepseek' => [ - 'base_url' => 'https://api.deepseek.com', - 'model' => 'deepseek-embedding', - ], - 'zhipu' => [ - 'base_url' => 'https://open.bigmodel.cn/api/paas/v4', - 'model' => 'embedding-3', - ], - ]; - - // 尝试其他支持 embedding 的服务(如 deepseek、zhipu、qianwen 等) - foreach ($vendorDefaults as $vendor => $defaults) { - $key = trim((string)($setting[$vendor . '_key'] ?? '')); - - if ($key !== '') { - $baseUrl = trim((string)($setting[$vendor . '_base_url'] ?? '')); - $baseUrl = $baseUrl ?: $defaults['base_url']; // 使用配置或默认值 - $agency = trim((string)($setting[$vendor . '_agency'] ?? '')); - - return [ - 'vendor' => $vendor, - 'model' => $defaults['model'], - 'api_key' => $key, - 'base_url' => rtrim($baseUrl, '/'), - 'agency' => $agency, - ]; - } - } - - return null; - } } diff --git a/app/Module/Manticore/ManticoreBase.php b/app/Module/Manticore/ManticoreBase.php index 8de4cc733..c8766293f 100644 --- a/app/Module/Manticore/ManticoreBase.php +++ b/app/Module/Manticore/ManticoreBase.php @@ -8,6 +8,7 @@ use App\Module\Base; use App\Module\AI; use PDO; use PDOException; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; /** @@ -21,6 +22,30 @@ class ManticoreBase private static ?PDO $pdo = null; private static bool $initialized = false; + /** + * 向量表结构版本;修改表结构/向量列参数时递增,触发已部署实例自动重建 + */ + private const SCHEMA_VERSION = 1; + + /** + * Auto Embeddings 的 MODEL_NAME。必须用 Manticore 不认识的名字: + * 已知名字(如 text-embedding-ada-002)会按引擎硬编码维度校验, + * 未知名字才会在建表时向 API_URL 探测真实维度(免费模型为 1024)。 + * 实际模型由 ai 插件的 EMBEDDING_MODEL 决定,此处仅为路由标签。 + */ + private const EMBEDDING_MODEL_NAME = 'openai/qwen3-embedding'; + + /** + * 批量写入分块上限:行数与字节预算(Manticore max_allowed_packet 默认 128MB,取保守值) + */ + private const BATCH_CHUNK_ROWS = 30; + private const BATCH_CHUNK_BYTES = 8388608; + + /** + * 5 张向量表名(键值即 VECTOR_TABLE_CONFIG 的 type) + */ + private const VECTOR_TABLES = ['msg', 'file', 'task', 'project', 'user']; + private string $host; private int $port; @@ -70,13 +95,120 @@ class ManticoreBase /** * 初始化表结构 + * + * 向量列使用 Manticore Auto Embeddings(MODEL_NAME/API_URL 指向 ai 插件 /embeddings), + * 引擎在写入/更新行时自动按 FROM 字段生成向量,无需 PHP 侧生成。 + * key_values 中的 vector:schema 标记记录当前结构指纹(结构版本/模型/端点/APP_KEY 哈希), + * 不匹配(首次安装、老版本升级、APP_KEY 轮换)即整体重建并重置同步指针,触发全量重灌。 */ private function initializeTables(PDO $pdo): void { try { - // 创建文件向量表 - // charset_table='non_cjk, cjk' 同时支持英文和中日韩文字 + // 键值表必须最先建(用于读取/持久化结构标记与同步指针) $pdo->exec(" + CREATE TABLE IF NOT EXISTS key_values ( + id BIGINT, + k STRING, + v TEXT + ) + "); + + $expected = self::schemaMarker(); + if (self::kvGetPdo($pdo, 'vector:schema') === $expected && self::allVectorTablesExist($pdo)) { + return; + } + + // 并发进程只允许一个执行重建;抢锁失败的进程本轮写入失败会进重试队列,无碍 + $lock = Cache::lock('manticore:schema-rebuild', 300); + if (!$lock->get()) { + return; + } + try { + foreach (self::vectorTableDDLs() as $table => $ddl) { + $pdo->exec("DROP TABLE IF EXISTS {$table}"); + // CREATE 时引擎会调用 ai 端点探测向量维度,ai 未就绪则抛错 + $pdo->exec($ddl); + } + } catch (\Throwable $e) { + // 建表失败(如 ai 插件未就绪/未升级):不写 marker,下个进程重试,可自愈 + Log::error('Manticore schema rebuild failed: ' . $e->getMessage()); + return; + } finally { + $lock->release(); + } + + self::resetSyncPointersPdo($pdo); + // 清理旧向量管道遗留键(vector:dim 与 vector:*LastId 指针) + $legacy = ["'vector:dim'"]; + foreach (self::VECTOR_TABLES as $t) { + $legacy[] = "'vector:manticore" . ucfirst($t) . "LastId'"; + } + $pdo->exec("DELETE FROM key_values WHERE k IN (" . implode(',', $legacy) . ")"); + self::kvSetPdo($pdo, 'vector:schema', $expected); + Log::info("Manticore vector tables rebuilt for auto-embeddings schema {$expected}"); + } catch (\Throwable $e) { + Log::error('Manticore initializeTables failed: ' . $e->getMessage()); + } + } + + /** + * ai 插件向量化端点地址(引擎 Auto Embeddings 与主程序查询侧共用) + */ + private static function embeddingsApiUrl(): string + { + $host = config('dootask.ai_host', 'ai'); + $port = (int) config('dootask.ai_port', 5001); + return "http://{$host}:{$port}/embeddings"; + } + + /** + * 写入表定义的派生密钥:sha256(APP_KEY:embeddings),与 ai 插件约定一致。 + * 不直接写 APP_KEY(Laravel 主密钥),避免其落入搜索引擎元数据/数据卷; + * 派生值不可反推,且仅授予 /embeddings 调用权限。 + */ + private static function embeddingsApiKey(): string + { + return hash('sha256', config('app.key') . ':embeddings'); + } + + /** + * 当前向量表结构指纹:结构版本/模型名/端点/APP_KEY 哈希任一变化都会触发整体重建 + */ + private static function schemaMarker(): string + { + return md5(self::SCHEMA_VERSION . '|' . self::EMBEDDING_MODEL_NAME . '|' + . self::embeddingsApiUrl() . '|' . hash('sha256', (string) config('app.key'))); + } + + /** + * 生成 Auto Embeddings 向量列定义(引擎按 FROM 字段自动生成/更新向量) + * + * 注意不写 KNN_DIMS(与 MODEL_NAME 互斥),维度由引擎建表时向端点探测。 + * + * @param string $from 参与向量化的字段(镜像旧 PHP 管道的拼接字段,逗号分隔) + */ + private static function vectorColumnDDL(string $from): string + { + return "content_vector float_vector knn_type='hnsw' hnsw_similarity='cosine'" + . " MODEL_NAME='" . self::EMBEDDING_MODEL_NAME . "'" + . " FROM='{$from}'" + . " API_KEY='" . self::embeddingsApiKey() . "'" + . " API_URL='" . self::embeddingsApiUrl() . "'" + . " API_TIMEOUT='60'"; + } + + /** + * 生成 5 张向量表的建表语句 + * + * charset_table='non_cjk, cjk' 同时支持英文和中日韩文字 + * + * @return array [table => DDL] + */ + private static function vectorTableDDLs(): array + { + $tail = "\n ) charset_table='non_cjk, cjk' morphology='icu_chinese'"; + return [ + 'file_vectors' => " CREATE TABLE IF NOT EXISTS file_vectors ( id BIGINT, file_id BIGINT, @@ -87,21 +219,8 @@ class ManticoreBase file_ext STRING, content TEXT, allowed_users MULTI, - content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine' - ) charset_table='non_cjk, cjk' morphology='icu_chinese' - "); - - // 创建键值存储表 - $pdo->exec(" - CREATE TABLE IF NOT EXISTS key_values ( - id BIGINT, - k STRING, - v TEXT - ) - "); - - // 创建用户向量表 - $pdo->exec(" + " . self::vectorColumnDDL('file_name,content') . $tail, + 'user_vectors' => " CREATE TABLE IF NOT EXISTS user_vectors ( id BIGINT, userid BIGINT, @@ -110,12 +229,8 @@ class ManticoreBase profession TEXT, tags TEXT, introduction TEXT, - content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine' - ) charset_table='non_cjk, cjk' morphology='icu_chinese' - "); - - // 创建项目向量表 - $pdo->exec(" + " . self::vectorColumnDDL('nickname,email,profession,tags,introduction') . $tail, + 'project_vectors' => " CREATE TABLE IF NOT EXISTS project_vectors ( id BIGINT, project_id BIGINT, @@ -124,12 +239,8 @@ class ManticoreBase project_name TEXT, project_desc TEXT, allowed_users MULTI, - content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine' - ) charset_table='non_cjk, cjk' morphology='icu_chinese' - "); - - // 创建任务向量表 - $pdo->exec(" + " . self::vectorColumnDDL('project_name,project_desc') . $tail, + 'task_vectors' => " CREATE TABLE IF NOT EXISTS task_vectors ( id BIGINT, task_id BIGINT, @@ -140,12 +251,8 @@ class ManticoreBase task_desc TEXT, task_content TEXT, allowed_users MULTI, - content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine' - ) charset_table='non_cjk, cjk' morphology='icu_chinese' - "); - - // 创建消息向量表 - $pdo->exec(" + " . self::vectorColumnDDL('task_name,task_desc,task_content') . $tail, + 'msg_vectors' => " CREATE TABLE IF NOT EXISTS msg_vectors ( id BIGINT, msg_id BIGINT, @@ -155,13 +262,69 @@ class ManticoreBase content TEXT, allowed_users MULTI, created_at BIGINT, - content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine' - ) charset_table='non_cjk, cjk' morphology='icu_chinese' - "); + " . self::vectorColumnDDL('content') . $tail, + ]; + } - // Tables initialized successfully - } catch (PDOException $e) { - // 表可能已存在,忽略初始化错误 + /** + * 检查 5 张向量表是否都已存在 + */ + private static function allVectorTablesExist(PDO $pdo): bool + { + try { + $stmt = $pdo->query("SHOW TABLES"); + $existing = []; + foreach ($stmt->fetchAll(PDO::FETCH_NUM) as $row) { + $existing[$row[0]] = true; + } + foreach (array_keys(self::vectorTableDDLs()) as $table) { + if (!isset($existing[$table])) { + return false; + } + } + return true; + } catch (\Throwable $e) { + return false; + } + } + + /** + * 直接用 PDO 读取 key_values(避免初始化期通过 ManticoreKeyValue 造成递归) + */ + private static function kvGetPdo(PDO $pdo, string $key): ?string + { + try { + $stmt = $pdo->prepare("SELECT v FROM key_values WHERE k = ?"); + $stmt->execute([$key]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return ($row && isset($row['v'])) ? (string)$row['v'] : null; + } catch (\Throwable $e) { + return null; + } + } + + /** + * 直接用 PDO 写入 key_values(同 ManticoreKeyValue::set 的 id 规则) + */ + private static function kvSetPdo(PDO $pdo, string $key, string $value): void + { + try { + $del = $pdo->prepare("DELETE FROM key_values WHERE k = ?"); + $del->execute([$key]); + $ins = $pdo->prepare("INSERT INTO key_values (id, k, v) VALUES (?, ?, ?)"); + $ins->execute([abs(crc32($key)), $key, $value]); + } catch (\Throwable $e) { + // 忽略 + } + } + + /** + * 重置全文同步进度指针(sync:*),触发全量重灌(向量由引擎随行自动生成) + */ + private static function resetSyncPointersPdo(PDO $pdo): void + { + foreach (self::VECTOR_TABLES as $t) { + self::kvSetPdo($pdo, "sync:manticore" . ucfirst($t) . "LastId", '0'); } } @@ -1810,10 +1973,10 @@ class ManticoreBase ]; /** - * 通用向量插入方法 + * 通用单行写入方法(REPLACE,向量由引擎 Auto Embeddings 自动生成) * * 使用 executeRaw 直接执行 SQL,避免 Manticore prepared statement - * 无法解析 MVA 和向量字段括号语法的问题。 + * 无法解析 MVA 字段括号语法的问题。 * * @param string $type 类型: msg/file/task/project/user * @param array $data 数据,键名对应字段名 @@ -1828,8 +1991,6 @@ class ManticoreBase $config = self::VECTOR_TABLE_CONFIG[$type]; $table = $config['table']; $pk = $config['pk']; - $fields = $config['fields']; - $mvaFields = $config['mva_fields']; // 检查主键 $pkValue = $data[$pk] ?? 0; @@ -1838,44 +1999,11 @@ class ManticoreBase } $instance = new self(); + [$fieldList, $valueList] = $instance->buildRowValues($config, $data); - // 先删除已存在的记录 - $instance->execute("DELETE FROM {$table} WHERE {$pk} = ?", [$pkValue]); - - // 构建字段列表和值 - $fieldList = []; - $valueList = []; - - // 处理普通字段 - foreach ($fields as $field) { - $fieldList[] = $field; - $value = $data[$field] ?? ($field === 'created_at' ? time() : (in_array($field, self::NUMERIC_FIELDS) ? 0 : '')); - - if (in_array($field, self::NUMERIC_FIELDS)) { - $valueList[] = (int)$value; - } else { - $valueList[] = $instance->quoteValue((string)$value); - } - } - - // 处理 MVA 字段 - foreach ($mvaFields as $mvaField) { - $fieldList[] = $mvaField; - $mvaData = $data[$mvaField] ?? []; - $valueList[] = !empty($mvaData) - ? '(' . implode(',', array_map('intval', $mvaData)) . ')' - : '()'; - } - - // 处理向量字段 - $vectorValue = $data['content_vector'] ?? null; - if ($vectorValue) { - $fieldList[] = 'content_vector'; - $valueList[] = str_replace(['[', ']'], ['(', ')'], $vectorValue); - } - - // 构建并执行 SQL - $sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES (" . implode(', ', $valueList) . ")"; + // REPLACE 按 id 原子替换整行,向量列由引擎按 FROM 字段自动重新生成。 + // 前提:所有 upsertXxxVector 均强制 id = 主键值,故 REPLACE(按 id) 与按主键去重等价 + $sql = "REPLACE INTO {$table} (" . implode(', ', $fieldList) . ") VALUES (" . implode(', ', $valueList) . ")"; $result = $instance->executeRaw($sql); @@ -1885,12 +2013,46 @@ class ManticoreBase ManticoreSyncFailure::removeSuccess($type, $pkValue, 'sync'); } else { // 失败则记录 - ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "INSERT failed for {$table}"); + ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "REPLACE failed for {$table}"); } return $result; } + /** + * 构建一行数据的字段列表与内联值(普通字段 + MVA 字段,向量列由引擎自动生成,不在此列) + * + * @param array $config VECTOR_TABLE_CONFIG 中的类型配置 + * @param array $data 行数据 + * @return array [fieldList, valueList] + */ + private function buildRowValues(array $config, array $data): array + { + $fieldList = []; + $valueList = []; + + foreach ($config['fields'] as $field) { + $fieldList[] = $field; + $value = $data[$field] ?? ($field === 'created_at' ? time() : (in_array($field, self::NUMERIC_FIELDS) ? 0 : '')); + + if (in_array($field, self::NUMERIC_FIELDS)) { + $valueList[] = (int)$value; + } else { + $valueList[] = $this->quoteValue((string)$value); + } + } + + foreach ($config['mva_fields'] as $mvaField) { + $fieldList[] = $mvaField; + $mvaData = $data[$mvaField] ?? []; + $valueList[] = !empty($mvaData) + ? '(' . implode(',', array_map('intval', $mvaData)) . ')' + : '()'; + } + + return [$fieldList, $valueList]; + } + /** * 通用向量删除方法 * @@ -1924,179 +2086,81 @@ class ManticoreBase } /** - * 通用批量更新向量方法(高性能版本) + * 通用批量写入方法:每块一条多行 REPLACE,向量由引擎 Auto Embeddings 自动生成 * - * 优化:将 N 条记录的 3N 次操作减少为 N+2 次操作 - * 1. 批量 SELECT 获取现有记录 (1次) - * 2. 预构建所有 INSERT SQL(验证数据完整性) - * 3. 批量 DELETE 删除旧记录 (1次) - * 4. 逐条 INSERT 新记录带向量 (N次,因向量字段无法批量绑定) + * 引擎对一条多行语句只调用一次向量化接口(实测 30 行 ≈ 0.9s), + * 多行语句失败是原子的;整块失败时回退逐行 upsertVector, + * 使单条坏行不毒化整批、且失败按真实主键记入重试表。 * * @param string $type 类型: msg/file/task/project/user - * @param array $vectorData 向量数据 [pk_value => vectorStr, ...] - * @return int 成功更新的数量 + * @param array $rows 行数据数组(与 upsertVector 的 $data 同构,需含 id 与主键) + * @return int 成功写入的数量 */ - public static function batchUpdateVectors(string $type, array $vectorData): int + public static function batchUpsertVectors(string $type, array $rows): int { - if (empty($vectorData) || !isset(self::VECTOR_TABLE_CONFIG[$type])) { + if (empty($rows) || !isset(self::VECTOR_TABLE_CONFIG[$type])) { return 0; } $config = self::VECTOR_TABLE_CONFIG[$type]; $table = $config['table']; $pk = $config['pk']; - $fields = $config['fields']; - $mvaFields = $config['mva_fields']; - $instance = new self(); - $ids = array_keys($vectorData); - // 1. 批量查询现有记录 - $placeholders = implode(',', array_fill(0, count($ids), '?')); - $existingRows = $instance->query( - "SELECT * FROM {$table} WHERE {$pk} IN ({$placeholders})", - $ids - ); - - if (empty($existingRows)) { - return 0; - } - - // 建立 pk => row 的映射 - $existingMap = []; - foreach ($existingRows as $row) { - $existingMap[$row[$pk]] = $row; - } - - $idsToUpdate = array_keys($existingMap); - if (empty($idsToUpdate)) { - return 0; - } - - // 2. 预构建所有 INSERT 语句(在删除前验证数据完整性) - $insertStatements = []; - foreach ($idsToUpdate as $pkValue) { - $existing = $existingMap[$pkValue]; - $vectorStr = $vectorData[$pkValue] ?? null; - - if (empty($vectorStr)) { + // 预构建每行的内联值,剔除无主键行 + $pending = []; + $fieldListRef = null; + foreach ($rows as $row) { + if (($row[$pk] ?? 0) <= 0) { continue; } - - // Manticore 向量使用 () 格式 - $vectorStr = str_replace(['[', ']'], ['(', ')'], $vectorStr); - - // 构建字段列表和值(直接内联值,不使用参数绑定) - $fieldList = $fields; - $quotedValues = []; - foreach ($fields as $field) { - $value = $existing[$field] ?? null; - // 处理默认值:数值字段用 0,时间戳字段用当前时间,其他用空字符串 - if ($value === null) { - if ($field === 'created_at') { - $value = time(); - } elseif (in_array($field, self::NUMERIC_FIELDS)) { - $value = 0; - } else { - $value = ''; - } - } - // 根据字段类型处理值 - if (in_array($field, self::NUMERIC_FIELDS)) { - $quotedValues[] = (int)$value; - } else { - $quotedValues[] = $instance->quoteValue((string)$value); - } - } - - // 构建 MVA 字段 - $mvaValuesStr = []; - foreach ($mvaFields as $mvaField) { - $fieldList[] = $mvaField; - $mvaValuesStr[] = !empty($existing[$mvaField]) - ? '(' . $existing[$mvaField] . ')' - : '()'; - } - - // 添加向量字段 - $fieldList[] = 'content_vector'; - - // 构建 SQL(所有值直接内联,使用 executeRaw 避免 prepared statement 解析问题) - $allValues = implode(', ', array_merge($quotedValues, $mvaValuesStr, [$vectorStr])); - $sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES ({$allValues})"; - - $insertStatements[] = ['sql' => $sql, 'pk' => $pkValue]; + [$fieldList, $valueList] = $instance->buildRowValues($config, $row); + $fieldListRef = $fieldList; + $valuesSql = '(' . implode(', ', $valueList) . ')'; + $pending[] = ['pk' => $row[$pk], 'sql' => $valuesSql, 'bytes' => strlen($valuesSql), 'row' => $row]; } - - // 如果没有有效的插入语句,直接返回 - if (empty($insertStatements)) { + if (empty($pending)) { return 0; } - // 3. 批量删除旧记录(只删除有有效向量的记录) - $validPks = array_column($insertStatements, 'pk'); - $deletePlaceholders = implode(',', array_fill(0, count($validPks), '?')); - $instance->execute( - "DELETE FROM {$table} WHERE {$pk} IN ({$deletePlaceholders})", - $validPks - ); + // 分块:行数上限 + 字节预算(文件内容可达 10 万字符/行) + $chunks = []; + $current = []; + $currentBytes = 0; + foreach ($pending as $item) { + if (!empty($current) + && (count($current) >= self::BATCH_CHUNK_ROWS || $currentBytes + $item['bytes'] > self::BATCH_CHUNK_BYTES)) { + $chunks[] = $current; + $current = []; + $currentBytes = 0; + } + $current[] = $item; + $currentBytes += $item['bytes']; + } + if (!empty($current)) { + $chunks[] = $current; + } - // 4. 逐条插入新记录(使用 executeRaw 避免 prepared statement 解析问题) $successCount = 0; - foreach ($insertStatements as $stmt) { - if ($instance->executeRaw($stmt['sql'])) { - $successCount++; - // 成功则删除失败记录(如果有) - ManticoreSyncFailure::removeSuccess($type, $stmt['pk'], 'sync'); + foreach ($chunks as $chunk) { + $sql = "REPLACE INTO {$table} (" . implode(', ', $fieldListRef) . ") VALUES " + . implode(', ', array_column($chunk, 'sql')); + if ($instance->executeRaw($sql)) { + $successCount += count($chunk); + ManticoreSyncFailure::removeSuccessBatch($type, array_column($chunk, 'pk'), 'sync'); } else { - // 失败则记录 - ManticoreSyncFailure::recordFailure($type, $stmt['pk'], 'sync', "Batch INSERT failed for {$table}"); + // 整块失败:回退逐行(REPLACE 幂等,重复写已生效行无害) + foreach ($chunk as $item) { + if (self::upsertVector($type, $item['row'])) { + $successCount++; + } + } } } return $successCount; } - /** - * 批量更新消息向量(兼容方法) - */ - public static function batchUpdateMsgVectors(array $vectorData): int - { - return self::batchUpdateVectors('msg', $vectorData); - } - - /** - * 批量更新文件向量 - */ - public static function batchUpdateFileVectors(array $vectorData): int - { - return self::batchUpdateVectors('file', $vectorData); - } - - /** - * 批量更新任务向量 - */ - public static function batchUpdateTaskVectors(array $vectorData): int - { - return self::batchUpdateVectors('task', $vectorData); - } - - /** - * 批量更新项目向量 - */ - public static function batchUpdateProjectVectors(array $vectorData): int - { - return self::batchUpdateVectors('project', $vectorData); - } - - /** - * 批量更新用户向量 - */ - public static function batchUpdateUserVectors(array $vectorData): int - { - return self::batchUpdateVectors('user', $vectorData); - } - // ============================== // 通用工具方法 // ============================== diff --git a/app/Module/Manticore/ManticoreFile.php b/app/Module/Manticore/ManticoreFile.php index 873061b84..a21afb97d 100644 --- a/app/Module/Manticore/ManticoreFile.php +++ b/app/Module/Manticore/ManticoreFile.php @@ -8,7 +8,6 @@ use App\Models\FileUser; use App\Module\Apps; use App\Module\Base; use App\Module\TextExtractor; -use App\Module\AI; use Illuminate\Support\Facades\Log; /** @@ -214,13 +213,12 @@ class ManticoreFile // ============================== /** - * 同步单个文件到 Manticore(含 allowed_users) + * 同步单个文件到 Manticore(含 allowed_users,向量由引擎 Auto Embeddings 自动生成) * * @param File $file 文件模型 - * @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成) * @return bool 是否成功 */ - public static function sync(File $file, bool $withVector = false): bool + public static function sync(File $file): bool { if (!Apps::isInstalled("search")) { return false; @@ -240,42 +238,7 @@ class ManticoreFile } try { - // 提取文件内容 - $content = self::extractFileContent($file); - - // 限制提取后的内容长度 - $content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH); - - // 只有明确要求时才生成向量(默认不生成,由后台任务处理) - $embedding = null; - if ($withVector && Apps::isInstalled('ai')) { - // 向量内容包含文件名和文件内容 - $vectorContent = self::buildVectorContent($file->name, $content); - if (!empty($vectorContent)) { - $embeddingResult = ManticoreBase::getEmbedding($vectorContent); - if (!empty($embeddingResult)) { - $embedding = '[' . implode(',', $embeddingResult) . ']'; - } - } - } - - // 获取文件的 allowed_users - $allowedUsers = self::getAllowedUsers($file); - - // 写入 Manticore(含 allowed_users) - $result = ManticoreBase::upsertFileVector([ - 'file_id' => $file->id, - 'userid' => $file->userid, - 'pshare' => $file->pshare ?? 0, - 'file_name' => $file->name, - 'file_type' => $file->type, - 'file_ext' => $file->ext, - 'content' => $content, - 'content_vector' => $embedding, - 'allowed_users' => $allowedUsers, - ]); - - return $result; + return ManticoreBase::upsertFileVector(self::buildRow($file)); } catch (\Exception $e) { Log::error('Manticore sync error: ' . $e->getMessage(), [ 'file_id' => $file->id, @@ -285,6 +248,30 @@ class ManticoreFile } } + /** + * 构建文件索引行数据(含提取的文件内容与 allowed_users) + * + * @param File $file 文件模型 + * @return array 行数据 + */ + private static function buildRow(File $file): array + { + // 提取文件内容并限制长度 + $content = mb_substr(self::extractFileContent($file), 0, self::MAX_CONTENT_LENGTH); + + return [ + 'id' => $file->id, + 'file_id' => $file->id, + 'userid' => $file->userid, + 'pshare' => $file->pshare ?? 0, + 'file_name' => $file->name, + 'file_type' => $file->type, + 'file_ext' => $file->ext, + 'content' => $content, + 'allowed_users' => self::getAllowedUsers($file), + ]; + } + /** * 根据文件扩展名获取最大文件大小限制 * @@ -317,25 +304,41 @@ class ManticoreFile } /** - * 批量同步文件 + * 批量同步文件(每块一条多行 REPLACE,向量由引擎自动生成) * * @param iterable $files 文件列表 - * @param bool $withVector 是否同时生成向量 * @return int 成功同步的数量 */ - public static function batchSync(iterable $files, bool $withVector = false): int + public static function batchSync(iterable $files): int { if (!Apps::isInstalled("search")) { return 0; } $count = 0; + $rows = []; foreach ($files as $file) { - if (self::sync($file, $withVector)) { + // 文件夹不索引 + if ($file->type === 'folder') { $count++; + continue; + } + // 超限文件删除旧索引 + if ($file->size > self::getMaxFileSizeByExt($file->ext)) { + self::delete($file->id); + $count++; + continue; + } + try { + $rows[] = self::buildRow($file); + } catch (\Exception $e) { + Log::error('Manticore file batchSync build error: ' . $e->getMessage(), [ + 'file_id' => $file->id, + ]); } } - return $count; + + return $count + ManticoreBase::batchUpsertVectors('file', $rows); } /** @@ -497,27 +500,6 @@ class ManticoreFile return $result['data'] ?? ''; } - /** - * 构建用于生成向量的内容 - * 包含文件名和文件内容,确保语义搜索能匹配文件名 - * - * @param string $fileName 文件名 - * @param string $content 文件内容 - * @return string 用于生成向量的文本 - */ - private static function buildVectorContent(string $fileName, string $content): string - { - $parts = []; - - if (!empty($fileName)) { - $parts[] = $fileName; - } - if (!empty($content)) { - $parts[] = $content; - } - - return implode(' ', $parts); - } /** * 清空所有索引 @@ -577,94 +559,4 @@ class ManticoreFile return false; } } - - // ============================== - // 批量向量生成方法 - // ============================== - - /** - * 批量生成文件向量 - * 用于后台异步处理,将已索引文件的向量批量生成 - * - * @param array $fileIds 文件ID数组 - * @param int $batchSize 每批 embedding 数量(默认20) - * @return int 成功处理的数量 - */ - public static function generateVectorsBatch(array $fileIds, int $batchSize = 20): int - { - if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($fileIds)) { - return 0; - } - - try { - // 1. 查询文件信息 - $files = File::whereIn('id', $fileIds) - ->where('type', '!=', 'folder') - ->get(); - - if ($files->isEmpty()) { - return 0; - } - - // 2. 提取每个文件的内容(包含文件名) - $fileContents = []; - foreach ($files as $file) { - // 检查文件大小限制 - $maxSize = self::getMaxFileSizeByExt($file->ext); - if ($file->size > $maxSize) { - continue; - } - - $content = self::extractFileContent($file); - // 向量内容包含文件名和文件内容 - $vectorContent = self::buildVectorContent($file->name, $content); - if (!empty($vectorContent)) { - // 限制内容长度 - $vectorContent = mb_substr($vectorContent, 0, self::MAX_CONTENT_LENGTH); - $fileContents[$file->id] = $vectorContent; - } - } - - if (empty($fileContents)) { - return 0; - } - - // 3. 分批处理 - $successCount = 0; - $chunks = array_chunk($fileContents, $batchSize, true); - - foreach ($chunks as $chunk) { - $texts = array_values($chunk); - $ids = array_keys($chunk); - - // 4. 批量获取 embedding - $result = AI::getBatchEmbeddings($texts); - if (!Base::isSuccess($result) || empty($result['data'])) { - continue; - } - - $embeddings = $result['data']; - - // 5. 构建批量更新数据 - $vectorData = []; - foreach ($ids as $index => $fileId) { - if (!isset($embeddings[$index]) || empty($embeddings[$index])) { - continue; - } - $vectorData[$fileId] = '[' . implode(',', $embeddings[$index]) . ']'; - } - - // 6. 批量更新向量 - if (!empty($vectorData)) { - $batchCount = ManticoreBase::batchUpdateFileVectors($vectorData); - $successCount += $batchCount; - } - } - - return $successCount; - } catch (\Exception $e) { - Log::error('ManticoreFile generateVectorsBatch error: ' . $e->getMessage()); - return 0; - } - } } diff --git a/app/Module/Manticore/ManticoreMsg.php b/app/Module/Manticore/ManticoreMsg.php index 3cbd05f79..d6ee1d050 100644 --- a/app/Module/Manticore/ManticoreMsg.php +++ b/app/Module/Manticore/ManticoreMsg.php @@ -5,8 +5,6 @@ namespace App\Module\Manticore; use App\Models\WebSocketDialogMsg; use App\Models\WebSocketDialogUser; use App\Module\Apps; -use App\Module\Base; -use App\Module\AI; use Carbon\Carbon; use DB; use Illuminate\Support\Facades\Log; @@ -328,13 +326,12 @@ class ManticoreMsg // ============================== /** - * 同步单个消息到 Manticore(含 allowed_users) + * 同步单个消息到 Manticore(含 allowed_users,向量由引擎 Auto Embeddings 自动生成) * * @param WebSocketDialogMsg $msg 消息模型 - * @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成) * @return bool 是否成功 */ - public static function sync(WebSocketDialogMsg $msg, bool $withVector = false): bool + public static function sync(WebSocketDialogMsg $msg): bool { if (!Apps::isInstalled("search")) { return false; @@ -347,37 +344,7 @@ class ManticoreMsg } try { - // 提取消息内容(使用 key 字段) - $content = $msg->key ?? ''; - - // 限制内容长度 - $content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH); - - // 只有明确要求时才生成向量(默认不生成,由后台任务处理) - $embedding = null; - if ($withVector && !empty($content) && Apps::isInstalled('ai')) { - $embeddingResult = ManticoreBase::getEmbedding($content); - if (!empty($embeddingResult)) { - $embedding = '[' . implode(',', $embeddingResult) . ']'; - } - } - - // 获取消息的 allowed_users - $allowedUsers = self::getAllowedUsers($msg); - - // 写入 Manticore(含 allowed_users) - $result = ManticoreBase::upsertMsgVector([ - 'msg_id' => $msg->id, - 'dialog_id' => $msg->dialog_id, - 'userid' => $msg->userid, - 'msg_type' => $msg->type, - 'content' => $content, - 'content_vector' => $embedding, - 'allowed_users' => $allowedUsers, - 'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(), - ]); - - return $result; + return ManticoreBase::upsertMsgVector(self::buildRow($msg)); } catch (\Exception $e) { Log::error('Manticore msg sync error: ' . $e->getMessage(), [ 'msg_id' => $msg->id, @@ -388,106 +355,59 @@ class ManticoreMsg } /** - * 批量同步消息 + * 构建消息索引行数据(含 allowed_users) + * + * @param WebSocketDialogMsg $msg 消息模型 + * @return array 行数据 + */ + private static function buildRow(WebSocketDialogMsg $msg): array + { + // 提取消息内容(使用 key 字段)并限制长度 + $content = mb_substr($msg->key ?? '', 0, self::MAX_CONTENT_LENGTH); + + return [ + 'id' => $msg->id, + 'msg_id' => $msg->id, + 'dialog_id' => $msg->dialog_id, + 'userid' => $msg->userid, + 'msg_type' => $msg->type, + 'content' => $content, + 'allowed_users' => self::getAllowedUsers($msg), + 'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(), + ]; + } + + /** + * 批量同步消息(每块一条多行 REPLACE,向量由引擎自动生成) * * @param iterable $msgs 消息列表 - * @param bool $withVector 是否同时生成向量 * @return int 成功同步的数量 */ - public static function batchSync(iterable $msgs, bool $withVector = false): int + public static function batchSync(iterable $msgs): int { if (!Apps::isInstalled("search")) { return 0; } $count = 0; + $rows = []; foreach ($msgs as $msg) { - if (self::sync($msg, $withVector)) { - $count++; - } - } - return $count; - } - - /** - * 批量生成向量(供后台任务调用) - * - * @param array $msgIds 消息ID数组 - * @param int $batchSize 每批 embedding 数量 - * @return int 成功生成向量的数量 - */ - public static function generateVectorsBatch(array $msgIds, int $batchSize = 20): int - { - if (!Apps::isInstalled("search") || !Apps::isInstalled('ai') || empty($msgIds)) { - return 0; - } - - $count = 0; - - // 分批处理 - foreach (array_chunk($msgIds, $batchSize) as $batchIds) { - // 获取消息 - $msgs = WebSocketDialogMsg::whereIn('id', $batchIds) - ->whereIn('type', self::INDEXABLE_TYPES) - ->where('bot', '!=', 1) - ->whereNotNull('key') - ->where('key', '!=', '') - ->get() - ->keyBy('id'); - - if ($msgs->isEmpty()) { + if (!self::shouldIndex($msg)) { + if (ManticoreBase::deleteMsgVector($msg->id)) { + $count++; + } continue; } - - // 准备文本 - $texts = []; - $idsArray = []; - foreach ($batchIds as $id) { - if (isset($msgs[$id])) { - $content = mb_substr($msgs[$id]->key ?? '', 0, self::MAX_CONTENT_LENGTH); - if (!empty($content)) { - $texts[] = $content; - $idsArray[] = $id; - } - } - } - - if (empty($texts)) { - continue; - } - - // 批量获取 embeddings - $result = AI::getBatchEmbeddings($texts); - - if (Base::isError($result)) { - continue; - } - - $embeddings = $result['data'] ?? []; - - // 构建批量更新数据 [msg_id => vectorStr] - $vectorData = []; - foreach ($embeddings as $index => $embedding) { - if (empty($embedding) || !is_array($embedding)) { - continue; - } - - $msgId = $idsArray[$index] ?? null; - if (!$msgId) { - continue; - } - - $vectorData[$msgId] = '[' . implode(',', $embedding) . ']'; - } - - // 批量更新向量(优化:减少数据库操作次数) - if (!empty($vectorData)) { - $batchCount = ManticoreBase::batchUpdateMsgVectors($vectorData); - $count += $batchCount; + try { + $rows[] = self::buildRow($msg); + } catch (\Exception $e) { + Log::error('Manticore msg batchSync build error: ' . $e->getMessage(), [ + 'msg_id' => $msg->id, + ]); } } - return $count; + return $count + ManticoreBase::batchUpsertVectors('msg', $rows); } /** diff --git a/app/Module/Manticore/ManticoreProject.php b/app/Module/Manticore/ManticoreProject.php index 2f9fba565..e492afd77 100644 --- a/app/Module/Manticore/ManticoreProject.php +++ b/app/Module/Manticore/ManticoreProject.php @@ -5,8 +5,6 @@ namespace App\Module\Manticore; use App\Models\Project; use App\Models\ProjectUser; use App\Module\Apps; -use App\Module\Base; -use App\Module\AI; use Illuminate\Support\Facades\Log; /** @@ -122,13 +120,12 @@ class ManticoreProject } /** - * 同步单个项目到 Manticore(含 allowed_users) + * 同步单个项目到 Manticore(含 allowed_users,向量由引擎 Auto Embeddings 自动生成) * * @param Project $project 项目模型 - * @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成) * @return bool 是否成功 */ - public static function sync(Project $project, bool $withVector = false): bool + public static function sync(Project $project): bool { if (!Apps::isInstalled("search")) { return false; @@ -140,33 +137,7 @@ class ManticoreProject } try { - // 构建用于搜索的文本内容 - $searchableContent = self::buildSearchableContent($project); - - // 只有明确要求时才生成向量(默认不生成,由后台任务处理) - $embedding = null; - if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) { - $embeddingResult = ManticoreBase::getEmbedding($searchableContent); - if (!empty($embeddingResult)) { - $embedding = '[' . implode(',', $embeddingResult) . ']'; - } - } - - // 获取项目成员列表(作为 allowed_users) - $allowedUsers = self::getAllowedUsers($project->id); - - // 写入 Manticore(含 allowed_users) - $result = ManticoreBase::upsertProjectVector([ - 'project_id' => $project->id, - 'userid' => $project->userid ?? 0, - 'personal' => $project->personal ?? 0, - 'project_name' => $project->name ?? '', - 'project_desc' => $project->desc ?? '', - 'content_vector' => $embedding, - 'allowed_users' => $allowedUsers, - ]); - - return $result; + return ManticoreBase::upsertProjectVector(self::buildRow($project)); } catch (\Exception $e) { Log::error('Manticore project sync error: ' . $e->getMessage(), [ 'project_id' => $project->id, @@ -177,45 +148,55 @@ class ManticoreProject } /** - * 构建可搜索的文本内容 + * 构建项目索引行数据(含 allowed_users) * * @param Project $project 项目模型 - * @return string 可搜索的文本 + * @return array 行数据 */ - private static function buildSearchableContent(Project $project): string + private static function buildRow(Project $project): array { - $parts = []; - - if (!empty($project->name)) { - $parts[] = $project->name; - } - if (!empty($project->desc)) { - $parts[] = $project->desc; - } - - return implode(' ', $parts); + return [ + 'id' => $project->id, + 'project_id' => $project->id, + 'userid' => $project->userid ?? 0, + 'personal' => $project->personal ?? 0, + 'project_name' => $project->name ?? '', + 'project_desc' => $project->desc ?? '', + 'allowed_users' => self::getAllowedUsers($project->id), + ]; } /** - * 批量同步项目 + * 批量同步项目(每块一条多行 REPLACE,向量由引擎自动生成) * * @param iterable $projects 项目列表 - * @param bool $withVector 是否同时生成向量 * @return int 成功同步的数量 */ - public static function batchSync(iterable $projects, bool $withVector = false): int + public static function batchSync(iterable $projects): int { if (!Apps::isInstalled("search")) { return 0; } $count = 0; + $rows = []; foreach ($projects as $project) { - if (self::sync($project, $withVector)) { - $count++; + if ($project->archived_at) { + if (self::delete($project->id)) { + $count++; + } + continue; + } + try { + $rows[] = self::buildRow($project); + } catch (\Exception $e) { + Log::error('Manticore project batchSync build error: ' . $e->getMessage(), [ + 'project_id' => $project->id, + ]); } } - return $count; + + return $count + ManticoreBase::batchUpsertVectors('project', $rows); } /** @@ -286,84 +267,4 @@ class ManticoreProject return false; } } - - // ============================== - // 批量向量生成方法 - // ============================== - - /** - * 批量生成项目向量 - * 用于后台异步处理,将已索引项目的向量批量生成 - * - * @param array $projectIds 项目ID数组 - * @param int $batchSize 每批 embedding 数量(默认20) - * @return int 成功处理的数量 - */ - public static function generateVectorsBatch(array $projectIds, int $batchSize = 20): int - { - if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($projectIds)) { - return 0; - } - - try { - // 1. 查询项目信息 - $projects = Project::whereIn('id', $projectIds) - ->whereNull('archived_at') - ->get(); - - if ($projects->isEmpty()) { - return 0; - } - - // 2. 提取每个项目的内容 - $projectContents = []; - foreach ($projects as $project) { - $searchableContent = self::buildSearchableContent($project); - if (!empty($searchableContent)) { - $projectContents[$project->id] = $searchableContent; - } - } - - if (empty($projectContents)) { - return 0; - } - - // 3. 分批处理 - $successCount = 0; - $chunks = array_chunk($projectContents, $batchSize, true); - - foreach ($chunks as $chunk) { - $texts = array_values($chunk); - $ids = array_keys($chunk); - - // 4. 批量获取 embedding - $result = AI::getBatchEmbeddings($texts); - if (!Base::isSuccess($result) || empty($result['data'])) { - continue; - } - - $embeddings = $result['data']; - - // 5. 构建批量更新数据 - $vectorData = []; - foreach ($ids as $index => $projectId) { - if (!isset($embeddings[$index]) || empty($embeddings[$index])) { - continue; - } - $vectorData[$projectId] = '[' . implode(',', $embeddings[$index]) . ']'; - } - - // 6. 批量更新向量 - if (!empty($vectorData)) { - $batchCount = ManticoreBase::batchUpdateProjectVectors($vectorData); - $successCount += $batchCount; - } - } - - return $successCount; - } catch (\Exception $e) { - Log::error('ManticoreProject generateVectorsBatch error: ' . $e->getMessage()); - return 0; - } - } } diff --git a/app/Module/Manticore/ManticoreTask.php b/app/Module/Manticore/ManticoreTask.php index 7e4666287..1a80bb3f5 100644 --- a/app/Module/Manticore/ManticoreTask.php +++ b/app/Module/Manticore/ManticoreTask.php @@ -9,7 +9,6 @@ use App\Models\ProjectTaskVisibilityUser; use App\Models\ProjectUser; use App\Module\Apps; use App\Module\Base; -use App\Module\AI; use Illuminate\Support\Facades\Log; /** @@ -189,13 +188,12 @@ class ManticoreTask // ============================== /** - * 同步单个任务到 Manticore(含 allowed_users) + * 同步单个任务到 Manticore(含 allowed_users,向量由引擎 Auto Embeddings 自动生成) * * @param ProjectTask $task 任务模型 - * @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成) * @return bool 是否成功 */ - public static function sync(ProjectTask $task, bool $withVector = false): bool + public static function sync(ProjectTask $task): bool { if (!Apps::isInstalled("search")) { return false; @@ -207,38 +205,7 @@ class ManticoreTask } try { - // 获取任务详细内容 - $taskContent = self::getTaskContent($task); - - // 构建用于搜索的文本内容 - $searchableContent = self::buildSearchableContent($task, $taskContent); - - // 只有明确要求时才生成向量(默认不生成,由后台任务处理) - $embedding = null; - if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) { - $embeddingResult = ManticoreBase::getEmbedding($searchableContent); - if (!empty($embeddingResult)) { - $embedding = '[' . implode(',', $embeddingResult) . ']'; - } - } - - // 获取任务的 allowed_users - $allowedUsers = self::getAllowedUsers($task); - - // 写入 Manticore(含 allowed_users) - $result = ManticoreBase::upsertTaskVector([ - 'task_id' => $task->id, - 'project_id' => $task->project_id ?? 0, - 'userid' => $task->userid ?? 0, - 'visibility' => $task->visibility ?? 1, - 'task_name' => $task->name ?? '', - 'task_desc' => $task->desc ?? '', - 'task_content' => $taskContent, - 'content_vector' => $embedding, - 'allowed_users' => $allowedUsers, - ]); - - return $result; + return ManticoreBase::upsertTaskVector(self::buildRow($task)); } catch (\Exception $e) { Log::error('Manticore task sync error: ' . $e->getMessage(), [ 'task_id' => $task->id, @@ -248,6 +215,27 @@ class ManticoreTask } } + /** + * 构建任务索引行数据(含详细内容与 allowed_users) + * + * @param ProjectTask $task 任务模型 + * @return array 行数据 + */ + private static function buildRow(ProjectTask $task): array + { + return [ + 'id' => $task->id, + 'task_id' => $task->id, + 'project_id' => $task->project_id ?? 0, + 'userid' => $task->userid ?? 0, + 'visibility' => $task->visibility ?? 1, + 'task_name' => $task->name ?? '', + 'task_desc' => $task->desc ?? '', + 'task_content' => self::getTaskContent($task), + 'allowed_users' => self::getAllowedUsers($task), + ]; + } + /** * 获取任务详细内容 * @@ -311,49 +299,36 @@ class ManticoreTask } /** - * 构建可搜索的文本内容 - * - * @param ProjectTask $task 任务模型 - * @param string $taskContent 任务详细内容 - * @return string 可搜索的文本 - */ - private static function buildSearchableContent(ProjectTask $task, string $taskContent): string - { - $parts = []; - - if (!empty($task->name)) { - $parts[] = $task->name; - } - if (!empty($task->desc)) { - $parts[] = $task->desc; - } - if (!empty($taskContent)) { - $parts[] = $taskContent; - } - - return implode(' ', $parts); - } - - /** - * 批量同步任务 + * 批量同步任务(每块一条多行 REPLACE,向量由引擎自动生成) * * @param iterable $tasks 任务列表 - * @param bool $withVector 是否同时生成向量 * @return int 成功同步的数量 */ - public static function batchSync(iterable $tasks, bool $withVector = false): int + public static function batchSync(iterable $tasks): int { if (!Apps::isInstalled("search")) { return 0; } $count = 0; + $rows = []; foreach ($tasks as $task) { - if (self::sync($task, $withVector)) { - $count++; + if ($task->archived_at || $task->deleted_at) { + if (self::delete($task->id)) { + $count++; + } + continue; + } + try { + $rows[] = self::buildRow($task); + } catch (\Exception $e) { + Log::error('Manticore task batchSync build error: ' . $e->getMessage(), [ + 'task_id' => $task->id, + ]); } } - return $count; + + return $count + ManticoreBase::batchUpsertVectors('task', $rows); } /** @@ -506,88 +481,4 @@ class ManticoreTask Log::error('Manticore cascadeToChildren error: ' . $e->getMessage(), ['task_id' => $taskId]); } } - - // ============================== - // 批量向量生成方法 - // ============================== - - /** - * 批量生成任务向量 - * 用于后台异步处理,将已索引任务的向量批量生成 - * - * @param array $taskIds 任务ID数组 - * @param int $batchSize 每批 embedding 数量(默认20) - * @return int 成功处理的数量 - */ - public static function generateVectorsBatch(array $taskIds, int $batchSize = 20): int - { - if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($taskIds)) { - return 0; - } - - try { - // 1. 查询任务信息 - $tasks = ProjectTask::whereIn('id', $taskIds) - ->whereNull('deleted_at') - ->whereNull('archived_at') - ->get(); - - if ($tasks->isEmpty()) { - return 0; - } - - // 2. 提取每个任务的内容 - $taskContents = []; - foreach ($tasks as $task) { - $taskContent = self::getTaskContent($task); - $searchableContent = self::buildSearchableContent($task, $taskContent); - if (!empty($searchableContent)) { - // 限制内容长度 - $searchableContent = mb_substr($searchableContent, 0, self::MAX_CONTENT_LENGTH); - $taskContents[$task->id] = $searchableContent; - } - } - - if (empty($taskContents)) { - return 0; - } - - // 3. 分批处理 - $successCount = 0; - $chunks = array_chunk($taskContents, $batchSize, true); - - foreach ($chunks as $chunk) { - $texts = array_values($chunk); - $ids = array_keys($chunk); - - // 4. 批量获取 embedding - $result = AI::getBatchEmbeddings($texts); - if (!Base::isSuccess($result) || empty($result['data'])) { - continue; - } - - $embeddings = $result['data']; - - // 5. 构建批量更新数据 - $vectorData = []; - foreach ($ids as $index => $taskId) { - if (!isset($embeddings[$index]) || empty($embeddings[$index])) { - continue; - } - $vectorData[$taskId] = '[' . implode(',', $embeddings[$index]) . ']'; - } - - // 6. 批量更新向量 - if (!empty($vectorData)) { - $batchCount = ManticoreBase::batchUpdateTaskVectors($vectorData); - $successCount += $batchCount; - } - } - - return $successCount; - } catch (\Exception $e) { - Log::error('ManticoreTask generateVectorsBatch error: ' . $e->getMessage()); - return 0; - } - } } diff --git a/app/Module/Manticore/ManticoreUser.php b/app/Module/Manticore/ManticoreUser.php index 5ed770b09..2a4869372 100644 --- a/app/Module/Manticore/ManticoreUser.php +++ b/app/Module/Manticore/ManticoreUser.php @@ -5,8 +5,6 @@ namespace App\Module\Manticore; use App\Models\User; use App\Models\UserTag; use App\Module\Apps; -use App\Module\Base; -use App\Module\AI; use Illuminate\Support\Facades\Log; /** @@ -123,13 +121,12 @@ class ManticoreUser } /** - * 同步单个用户到 Manticore + * 同步单个用户到 Manticore(向量由引擎 Auto Embeddings 按行内文本自动生成) * * @param User $user 用户模型 - * @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成) * @return bool 是否成功 */ - public static function sync(User $user, bool $withVector = false): bool + public static function sync(User $user): bool { if (!Apps::isInstalled("search")) { return false; @@ -146,33 +143,7 @@ class ManticoreUser } try { - // 获取用户标签(Top 10) - $tags = self::getUserTags($user->userid); - - // 构建用于搜索的文本内容 - $searchableContent = self::buildSearchableContent($user, $tags); - - // 只有明确要求时才生成向量(默认不生成,由后台任务处理) - $embedding = null; - if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) { - $embeddingResult = ManticoreBase::getEmbedding($searchableContent); - if (!empty($embeddingResult)) { - $embedding = '[' . implode(',', $embeddingResult) . ']'; - } - } - - // 写入 Manticore - $result = ManticoreBase::upsertUserVector([ - 'userid' => $user->userid, - 'nickname' => $user->nickname ?? '', - 'email' => $user->email ?? '', - 'profession' => $user->profession ?? '', - 'tags' => $tags, - 'introduction' => $user->introduction ?? '', - 'content_vector' => $embedding, - ]); - - return $result; + return ManticoreBase::upsertUserVector(self::buildRow($user)); } catch (\Exception $e) { Log::error('Manticore user sync error: ' . $e->getMessage(), [ 'userid' => $user->userid, @@ -183,55 +154,61 @@ class ManticoreUser } /** - * 构建可搜索的文本内容 + * 构建用户索引行数据(含标签 Top 10) * * @param User $user 用户模型 - * @param string $tags 用户标签(空格分隔) - * @return string 可搜索的文本 + * @return array 行数据 */ - private static function buildSearchableContent(User $user, string $tags = ''): string + private static function buildRow(User $user): array { - $parts = []; + $tags = self::getUserTags($user->userid); - if (!empty($user->nickname)) { - $parts[] = $user->nickname; - } - if (!empty($user->email)) { - $parts[] = $user->email; - } - if (!empty($user->profession)) { - $parts[] = $user->profession; - } - if (!empty($tags)) { - $parts[] = $tags; - } - if (!empty($user->introduction)) { - $parts[] = $user->introduction; - } - - return implode(' ', $parts); + return [ + 'id' => $user->userid, + 'userid' => $user->userid, + 'nickname' => $user->nickname ?? '', + 'email' => $user->email ?? '', + 'profession' => $user->profession ?? '', + 'tags' => $tags, + 'introduction' => $user->introduction ?? '', + ]; } /** - * 批量同步用户 + * 批量同步用户(每块一条多行 REPLACE,向量由引擎自动生成) * * @param iterable $users 用户列表 - * @param bool $withVector 是否同时生成向量 * @return int 成功同步的数量 */ - public static function batchSync(iterable $users, bool $withVector = false): int + public static function batchSync(iterable $users): int { if (!Apps::isInstalled("search")) { return 0; } $count = 0; + $rows = []; foreach ($users as $user) { - if (self::sync($user, $withVector)) { + if ($user->bot) { $count++; + continue; + } + if ($user->disable_at) { + if (self::delete($user->userid)) { + $count++; + } + continue; + } + try { + $rows[] = self::buildRow($user); + } catch (\Exception $e) { + Log::error('Manticore user batchSync build error: ' . $e->getMessage(), [ + 'userid' => $user->userid, + ]); } } - return $count; + + return $count + ManticoreBase::batchUpsertVectors('user', $rows); } /** @@ -276,87 +253,4 @@ class ManticoreUser return ManticoreBase::getIndexedUserCount(); } - - // ============================== - // 批量向量生成方法 - // ============================== - - /** - * 批量生成用户向量 - * 用于后台异步处理,将已索引用户的向量批量生成 - * - * @param array $userIds 用户ID数组 - * @param int $batchSize 每批 embedding 数量(默认20) - * @return int 成功处理的数量 - */ - public static function generateVectorsBatch(array $userIds, int $batchSize = 20): int - { - if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($userIds)) { - return 0; - } - - try { - // 1. 查询用户信息 - $users = User::whereIn('userid', $userIds) - ->where('bot', 0) - ->whereNull('disable_at') - ->get(); - - if ($users->isEmpty()) { - return 0; - } - - // 2. 提取每个用户的内容(包含标签) - $userContents = []; - foreach ($users as $user) { - $tags = self::getUserTags($user->userid); - $searchableContent = self::buildSearchableContent($user, $tags); - if (!empty($searchableContent)) { - $userContents[$user->userid] = $searchableContent; - } - } - - if (empty($userContents)) { - return 0; - } - - // 3. 分批处理 - $successCount = 0; - $chunks = array_chunk($userContents, $batchSize, true); - - foreach ($chunks as $chunk) { - $texts = array_values($chunk); - $ids = array_keys($chunk); - - // 4. 批量获取 embedding - $result = AI::getBatchEmbeddings($texts); - if (!Base::isSuccess($result) || empty($result['data'])) { - continue; - } - - $embeddings = $result['data']; - - // 5. 构建批量更新数据 - $vectorData = []; - foreach ($ids as $index => $userid) { - if (!isset($embeddings[$index]) || empty($embeddings[$index])) { - continue; - } - $vectorData[$userid] = '[' . implode(',', $embeddings[$index]) . ']'; - } - - // 6. 批量更新向量 - if (!empty($vectorData)) { - $batchCount = ManticoreBase::batchUpdateUserVectors($vectorData); - $successCount += $batchCount; - } - } - - return $successCount; - } catch (\Exception $e) { - Log::error('ManticoreUser generateVectorsBatch error: ' . $e->getMessage()); - return 0; - } - } } - diff --git a/app/Tasks/ManticoreSyncTask.php b/app/Tasks/ManticoreSyncTask.php index 949588dac..7cdd21649 100644 --- a/app/Tasks/ManticoreSyncTask.php +++ b/app/Tasks/ManticoreSyncTask.php @@ -235,11 +235,8 @@ class ManticoreSyncTask extends AbstractTask } Cache::put("ManticoreSyncTask:CheckTime", time(), Carbon::now()->addMinutes(5)); - // 执行增量全文索引同步 + // 执行增量全文索引同步(向量由 Manticore Auto Embeddings 随行自动生成) $this->runIncrementalSync(); - - // 执行向量生成 - $this->runVectorGeneration(); } /** @@ -261,22 +258,6 @@ class ManticoreSyncTask extends AbstractTask @shell_exec("php /var/www/artisan manticore:retry-failures 2>&1 &"); } - /** - * 执行向量生成(兜底触发) - * - * 命令内部有锁机制,如果已在运行会自动跳过 - * 命令会持续处理直到无待处理数据,然后自动退出 - */ - private function runVectorGeneration(): void - { - if (!Apps::isInstalled("ai")) { - return; - } - - // 启动向量生成命令 - @shell_exec("php /var/www/artisan manticore:generate-vectors --type=all --batch=50 2>&1 &"); - } - public function end() { } diff --git a/config/dootask.php b/config/dootask.php index ee556ee05..a7990b3cd 100644 --- a/config/dootask.php +++ b/config/dootask.php @@ -26,6 +26,12 @@ return [ // Manticore 全文搜索服务端口(ManticoreBase) 'search_port' => env('SEARCH_PORT', 9306), + // AI 插件服务主机(AI::getEmbedding 走 ai 插件 /embeddings 免费向量模型) + 'ai_host' => env('AI_HOST', 'ai'), + + // AI 插件服务端口(AI::getEmbedding) + 'ai_port' => env('AI_PORT', 5001), + // 文件回收站自动清空天数(DeleteTmpTask) 'auto_empty_file_recycle' => env('AUTO_EMPTY_FILE_RECYCLE', 365), diff --git a/resources/assets/sass/components/search-box.scss b/resources/assets/sass/components/search-box.scss index 7e4ed9c3f..5a4d26e2b 100755 --- a/resources/assets/sass/components/search-box.scss +++ b/resources/assets/sass/components/search-box.scss @@ -90,14 +90,15 @@ display: flex; align-items: center; gap: 6px; - padding: 5px 14px; + padding: 4px 14px; + line-height: 24px; border-radius: 16px; - cursor: pointer; font-size: 13px; font-weight: 500; color: #666; background-color: #f0f0f0; transition: all 0.2s ease; + cursor: pointer; > i { font-size: 14px;