feat: 添加用户标签功能,更新用户索引以支持标签创建、更新和删除事件

This commit is contained in:
kuaifan 2026-01-04 07:13:08 +00:00
parent f42250b8b7
commit 90a5624877
9 changed files with 280 additions and 30 deletions

View File

@ -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;
}
}

View File

@ -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'] ?? ''
];
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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)) {

View File

@ -0,0 +1,69 @@
<?php
namespace App\Observers;
use App\Models\User;
use App\Models\UserTag;
use App\Tasks\ManticoreSyncTask;
class UserTagObserver extends AbstractObserver
{
/**
* Handle the UserTag "created" event.
* 标签创建时,触发用户索引更新
*
* @param \App\Models\UserTag $userTag
* @return void
*/
public function created(UserTag $userTag)
{
$this->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()));
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Observers;
use App\Models\User;
use App\Models\UserTag;
use App\Models\UserTagRecognition;
use App\Tasks\ManticoreSyncTask;
class UserTagRecognitionObserver extends AbstractObserver
{
/**
* Handle the UserTagRecognition "created" event.
* 认可创建时,标签排序可能变化,触发用户索引更新
*
* @param \App\Models\UserTagRecognition $recognition
* @return void
*/
public function created(UserTagRecognition $recognition)
{
$this->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()));
}
}

View File

@ -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);

View File

@ -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,