From 90a5624877734d3a4d0353594be9a530088aa62a Mon Sep 17 00:00:00 2001 From: kuaifan Date: Sun, 4 Jan 2026 07:13:08 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=B4=A2=E5=BC=95=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=88=9B=E5=BB=BA=E3=80=81=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=92=8C=E5=88=A0=E9=99=A4=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Api/SearchController.php | 49 ++++++++++++- app/Module/Manticore/ManticoreBase.php | 24 +++---- app/Module/Manticore/ManticoreFile.php | 44 +++++++++--- app/Module/Manticore/ManticoreUser.php | 39 +++++++++-- app/Observers/UserObserver.php | 4 +- app/Observers/UserTagObserver.php | 69 +++++++++++++++++++ app/Observers/UserTagRecognitionObserver.php | 60 ++++++++++++++++ app/Providers/EventServiceProvider.php | 6 ++ resources/assets/js/components/SearchBox.vue | 15 +++- 9 files changed, 280 insertions(+), 30 deletions(-) create mode 100644 app/Observers/UserTagObserver.php create mode 100644 app/Observers/UserTagRecognitionObserver.php diff --git a/app/Http/Controllers/Api/SearchController.php b/app/Http/Controllers/Api/SearchController.php index 3eca124f7..a7af4da32 100644 --- a/app/Http/Controllers/Api/SearchController.php +++ b/app/Http/Controllers/Api/SearchController.php @@ -7,6 +7,7 @@ use App\Models\File; use App\Models\Project; use App\Models\ProjectTask; use App\Models\User; +use App\Models\UserTag; use App\Models\WebSocketDialog; use App\Models\WebSocketDialogMsg; use App\Module\Base; @@ -67,9 +68,14 @@ class SearchController extends AbstractController foreach ($results as &$item) { $userData = $users->get($item['userid']); if ($userData) { + // 标签直接从 Manticore 搜索结果获取(空格分隔的字符串转数组) + $tagsStr = $item['tags'] ?? ''; + $searchTags = !empty($tagsStr) ? preg_split('/\s+/', trim($tagsStr)) : []; + $item = array_merge($userData->toArray(), [ 'relevance' => $item['relevance'] ?? 0, 'introduction_preview' => $item['introduction_preview'] ?? null, + 'search_tags' => $searchTags, ]); } } @@ -99,10 +105,15 @@ class SearchController extends AbstractController ->take($take) ->get(); - return $users->map(function ($user) { + // 获取用户标签 + $userids = $users->pluck('userid')->toArray(); + $userTags = $this->getUserTagsMap($userids); + + return $users->map(function ($user) use ($userTags) { return array_merge($user->toArray(), [ 'relevance' => 0, 'introduction_preview' => null, + 'search_tags' => $userTags[$user->userid] ?? [], ]); })->toArray(); } @@ -569,4 +580,40 @@ class SearchController extends AbstractController return Base::retSuccess('success', []); } } + + /** + * 批量获取用户标签映射 + * + * @param array $userids 用户ID数组 + * @return array 用户ID => 标签名称数组的映射 + */ + private function getUserTagsMap(array $userids): array + { + if (empty($userids)) { + return []; + } + + // 获取所有用户的标签(带认可数) + $tags = UserTag::whereIn('user_id', $userids) + ->withCount('recognitions') + ->get(); + + // 按用户分组,每个用户取 Top 10 标签 + $result = []; + foreach ($userids as $userid) { + $result[$userid] = []; + } + + $userTags = $tags->groupBy('user_id'); + foreach ($userTags as $userid => $tagCollection) { + $result[$userid] = $tagCollection + ->sortByDesc('recognitions_count') + ->take(10) + ->pluck('name') + ->values() + ->toArray(); + } + + return $result; + } } diff --git a/app/Module/Manticore/ManticoreBase.php b/app/Module/Manticore/ManticoreBase.php index d18441e31..66482aa5c 100644 --- a/app/Module/Manticore/ManticoreBase.php +++ b/app/Module/Manticore/ManticoreBase.php @@ -107,8 +107,8 @@ class ManticoreBase userid BIGINT, nickname TEXT, email STRING, - tel STRING, profession TEXT, + tags TEXT, introduction TEXT, content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine' ) charset_table='chinese' morphology='icu_chinese' @@ -764,17 +764,17 @@ class ManticoreBase $escapedKeyword = self::escapeMatch($keyword); $sql = " - SELECT + SELECT id, userid, nickname, email, - tel, profession, + tags, introduction, WEIGHT() as relevance FROM user_vectors - WHERE MATCH('@(nickname,profession,introduction) {$escapedKeyword}') + WHERE MATCH('@(nickname,profession,tags,introduction) {$escapedKeyword}') ORDER BY relevance DESC LIMIT " . (int)$limit . " OFFSET " . (int)$offset; @@ -798,13 +798,13 @@ class ManticoreBase $vectorStr = '(' . implode(',', $queryVector) . ')'; $sql = " - SELECT + SELECT id, userid, nickname, email, - tel, profession, + tags, introduction, KNN_DIST() as distance FROM user_vectors @@ -891,8 +891,8 @@ class ManticoreBase $vectorValue = $data['content_vector'] ?? null; if ($vectorValue) { $vectorValue = str_replace(['[', ']'], ['(', ')'], $vectorValue); - $sql = "INSERT INTO user_vectors - (id, userid, nickname, email, tel, profession, introduction, content_vector) + $sql = "INSERT INTO user_vectors + (id, userid, nickname, email, profession, tags, introduction, content_vector) VALUES (?, ?, ?, ?, ?, ?, ?, {$vectorValue})"; $params = [ @@ -900,13 +900,13 @@ class ManticoreBase $userid, $data['nickname'] ?? '', $data['email'] ?? '', - $data['tel'] ?? '', $data['profession'] ?? '', + $data['tags'] ?? '', $data['introduction'] ?? '' ]; } else { - $sql = "INSERT INTO user_vectors - (id, userid, nickname, email, tel, profession, introduction) + $sql = "INSERT INTO user_vectors + (id, userid, nickname, email, profession, tags, introduction) VALUES (?, ?, ?, ?, ?, ?, ?)"; $params = [ @@ -914,8 +914,8 @@ class ManticoreBase $userid, $data['nickname'] ?? '', $data['email'] ?? '', - $data['tel'] ?? '', $data['profession'] ?? '', + $data['tags'] ?? '', $data['introduction'] ?? '' ]; } diff --git a/app/Module/Manticore/ManticoreFile.php b/app/Module/Manticore/ManticoreFile.php index 54022a8da..52c8cec63 100644 --- a/app/Module/Manticore/ManticoreFile.php +++ b/app/Module/Manticore/ManticoreFile.php @@ -250,10 +250,14 @@ class ManticoreFile // 只有明确要求时才生成向量(默认不生成,由后台任务处理) $embedding = null; - if ($withVector && !empty($content) && Apps::isInstalled('ai')) { - $embeddingResult = ManticoreBase::getEmbedding($content); - if (!empty($embeddingResult)) { - $embedding = '[' . implode(',', $embeddingResult) . ']'; + if ($withVector && Apps::isInstalled('ai')) { + // 向量内容包含文件名和文件内容 + $vectorContent = self::buildVectorContent($file->name, $content); + if (!empty($vectorContent)) { + $embeddingResult = ManticoreBase::getEmbedding($vectorContent); + if (!empty($embeddingResult)) { + $embedding = '[' . implode(',', $embeddingResult) . ']'; + } } } @@ -399,6 +403,28 @@ class ManticoreFile return ''; } + /** + * 构建用于生成向量的内容 + * 包含文件名和文件内容,确保语义搜索能匹配文件名 + * + * @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); + } + /** * 清空所有索引 * @@ -486,7 +512,7 @@ class ManticoreFile return 0; } - // 2. 提取每个文件的内容 + // 2. 提取每个文件的内容(包含文件名) $fileContents = []; foreach ($files as $file) { // 检查文件大小限制 @@ -496,10 +522,12 @@ class ManticoreFile } $content = self::extractFileContent($file); - if (!empty($content)) { + // 向量内容包含文件名和文件内容 + $vectorContent = self::buildVectorContent($file->name, $content); + if (!empty($vectorContent)) { // 限制内容长度 - $content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH); - $fileContents[$file->id] = $content; + $vectorContent = mb_substr($vectorContent, 0, self::MAX_CONTENT_LENGTH); + $fileContents[$file->id] = $vectorContent; } } diff --git a/app/Module/Manticore/ManticoreUser.php b/app/Module/Manticore/ManticoreUser.php index bf0fd95b6..9b1ae7fa0 100644 --- a/app/Module/Manticore/ManticoreUser.php +++ b/app/Module/Manticore/ManticoreUser.php @@ -3,6 +3,7 @@ namespace App\Module\Manticore; use App\Models\User; +use App\Models\UserTag; use App\Module\Apps; use App\Module\Base; use App\Module\AI; @@ -90,8 +91,8 @@ class ManticoreUser 'userid' => $item['userid'], 'nickname' => $item['nickname'], 'email' => $item['email'], - 'tel' => $item['tel'], 'profession' => $item['profession'], + 'tags' => $item['tags'] ?? '', 'introduction_preview' => isset($item['introduction']) ? mb_substr($item['introduction'], 0, 200) : null, 'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0, ]; @@ -103,6 +104,24 @@ class ManticoreUser // 同步方法 // ============================== + /** + * 获取用户的标签(按认可数排序,最多10个) + * + * @param int $userid 用户ID + * @return string 标签名称,空格分隔 + */ + public static function getUserTags(int $userid): string + { + $tags = UserTag::where('user_id', $userid) + ->withCount('recognitions') + ->orderByDesc('recognitions_count') + ->limit(10) + ->pluck('name') + ->toArray(); + + return implode(' ', $tags); + } + /** * 同步单个用户到 Manticore * @@ -127,8 +146,11 @@ class ManticoreUser } try { + // 获取用户标签(Top 10) + $tags = self::getUserTags($user->userid); + // 构建用于搜索的文本内容 - $searchableContent = self::buildSearchableContent($user); + $searchableContent = self::buildSearchableContent($user, $tags); // 只有明确要求时才生成向量(默认不生成,由后台任务处理) $embedding = null; @@ -144,8 +166,8 @@ class ManticoreUser 'userid' => $user->userid, 'nickname' => $user->nickname ?? '', 'email' => $user->email ?? '', - 'tel' => $user->tel ?? '', 'profession' => $user->profession ?? '', + 'tags' => $tags, 'introduction' => $user->introduction ?? '', 'content_vector' => $embedding, ]); @@ -164,9 +186,10 @@ class ManticoreUser * 构建可搜索的文本内容 * * @param User $user 用户模型 + * @param string $tags 用户标签(空格分隔) * @return string 可搜索的文本 */ - private static function buildSearchableContent(User $user): string + private static function buildSearchableContent(User $user, string $tags = ''): string { $parts = []; @@ -179,6 +202,9 @@ class ManticoreUser if (!empty($user->profession)) { $parts[] = $user->profession; } + if (!empty($tags)) { + $parts[] = $tags; + } if (!empty($user->introduction)) { $parts[] = $user->introduction; } @@ -280,10 +306,11 @@ class ManticoreUser return 0; } - // 2. 提取每个用户的内容 + // 2. 提取每个用户的内容(包含标签) $userContents = []; foreach ($users as $user) { - $searchableContent = self::buildSearchableContent($user); + $tags = self::getUserTags($user->userid); + $searchableContent = self::buildSearchableContent($user, $tags); if (!empty($searchableContent)) { $userContents[$user->userid] = $searchableContent; } diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php index 1c8eaf8b3..cdfd7689d 100644 --- a/app/Observers/UserObserver.php +++ b/app/Observers/UserObserver.php @@ -35,8 +35,8 @@ class UserObserver extends AbstractObserver return; } - // 检查是否有搜索相关字段变化 - $searchableFields = ['nickname', 'email', 'tel', 'profession', 'introduction', 'disable_at']; + // 检查是否有搜索相关字段变化(不含 tel,因为 Manticore 不索引电话) + $searchableFields = ['nickname', 'email', 'profession', 'introduction', 'disable_at']; $isDirty = false; foreach ($searchableFields as $field) { if ($user->isDirty($field)) { diff --git a/app/Observers/UserTagObserver.php b/app/Observers/UserTagObserver.php new file mode 100644 index 000000000..dae438be7 --- /dev/null +++ b/app/Observers/UserTagObserver.php @@ -0,0 +1,69 @@ +syncUserToManticore($userTag->user_id); + } + + /** + * Handle the UserTag "updated" event. + * 标签更新时,触发用户索引更新 + * + * @param \App\Models\UserTag $userTag + * @return void + */ + public function updated(UserTag $userTag) + { + // 只有标签名称变化时才需要更新 + if ($userTag->isDirty('name')) { + $this->syncUserToManticore($userTag->user_id); + } + } + + /** + * Handle the UserTag "deleted" event. + * 标签删除时,触发用户索引更新 + * + * @param \App\Models\UserTag $userTag + * @return void + */ + public function deleted(UserTag $userTag) + { + $this->syncUserToManticore($userTag->user_id); + } + + /** + * 触发用户同步到 Manticore + * + * @param int $userid 用户ID + * @return void + */ + private function syncUserToManticore(int $userid) + { + if ($userid <= 0) { + return; + } + + $user = User::find($userid); + if (!$user || $user->bot || $user->disable_at) { + return; + } + + self::taskDeliver(new ManticoreSyncTask('user_sync', $user->toArray())); + } +} diff --git a/app/Observers/UserTagRecognitionObserver.php b/app/Observers/UserTagRecognitionObserver.php new file mode 100644 index 000000000..91c13dfab --- /dev/null +++ b/app/Observers/UserTagRecognitionObserver.php @@ -0,0 +1,60 @@ +syncUserByTagId($recognition->tag_id); + } + + /** + * Handle the UserTagRecognition "deleted" event. + * 认可删除时,标签排序可能变化,触发用户索引更新 + * + * @param \App\Models\UserTagRecognition $recognition + * @return void + */ + public function deleted(UserTagRecognition $recognition) + { + $this->syncUserByTagId($recognition->tag_id); + } + + /** + * 根据标签ID触发用户同步 + * + * @param int $tagId 标签ID + * @return void + */ + private function syncUserByTagId(int $tagId) + { + if ($tagId <= 0) { + return; + } + + $tag = UserTag::find($tagId); + if (!$tag) { + return; + } + + $user = User::find($tag->user_id); + if (!$user || $user->bot || $user->disable_at) { + return; + } + + self::taskDeliver(new ManticoreSyncTask('user_sync', $user->toArray())); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index d006122f9..ad373763a 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -10,6 +10,8 @@ use App\Models\ProjectTaskUser; use App\Models\ProjectTaskVisibilityUser; use App\Models\ProjectUser; use App\Models\User; +use App\Models\UserTag; +use App\Models\UserTagRecognition; use App\Models\WebSocketDialog; use App\Models\WebSocketDialogMsg; use App\Models\WebSocketDialogUser; @@ -21,6 +23,8 @@ use App\Observers\ProjectTaskUserObserver; use App\Observers\ProjectTaskVisibilityUserObserver; use App\Observers\ProjectUserObserver; use App\Observers\UserObserver; +use App\Observers\UserTagObserver; +use App\Observers\UserTagRecognitionObserver; use App\Observers\WebSocketDialogMsgObserver; use App\Observers\WebSocketDialogObserver; use App\Observers\WebSocketDialogUserObserver; @@ -56,6 +60,8 @@ class EventServiceProvider extends ServiceProvider ProjectTaskVisibilityUser::observe(ProjectTaskVisibilityUserObserver::class); ProjectUser::observe(ProjectUserObserver::class); User::observe(UserObserver::class); + UserTag::observe(UserTagObserver::class); + UserTagRecognition::observe(UserTagRecognitionObserver::class); WebSocketDialog::observe(WebSocketDialogObserver::class); WebSocketDialogMsg::observe(WebSocketDialogMsgObserver::class); WebSocketDialogUser::observe(WebSocketDialogUserObserver::class); diff --git a/resources/assets/js/components/SearchBox.vue b/resources/assets/js/components/SearchBox.vue index eff96eefe..5b581ed2b 100755 --- a/resources/assets/js/components/SearchBox.vue +++ b/resources/assets/js/components/SearchBox.vue @@ -505,11 +505,24 @@ export default { }, }).then(({data}) => { const items = data.map(item => { + // 构建标签:显示匹配搜索关键词的标签 + const tags = []; + if (item.search_tags && item.search_tags.length > 0) { + const keyLower = key.toLowerCase(); + item.search_tags.forEach(tagName => { + if (tagName.toLowerCase().includes(keyLower)) { + tags.push({ + name: tagName, + style: 'background-color:#2d8cf0', + }); + } + }); + } return { key, type: 'contact', icons: ['user', item.userid], - tags: [], + tags, id: item.userid, title: item.nickname,