feat: 扩展 SeekDB 支持联系人、项目、任务的 AI 搜索
- 合并 SeekDBFileSyncTask 到 SeekDBSyncTask - 统一 AI 搜索 API 入口
This commit is contained in:
File diff suppressed because it is too large
Load Diff
384
app/Module/SeekDB/SeekDBProject.php
Normal file
384
app/Module/SeekDB/SeekDBProject.php
Normal file
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\SeekDB;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* SeekDB 项目搜索类
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索项目: search($userid, $keyword, $searchType, $limit);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(Project $project);
|
||||
* - 批量同步: batchSync($projects);
|
||||
* - 删除索引: delete($projectId);
|
||||
*
|
||||
* 3. 成员关系方法
|
||||
* - 添加成员: addProjectUser($projectId, $userid);
|
||||
* - 删除成员: removeProjectUser($projectId, $userid);
|
||||
* - 同步所有成员: syncProjectUsers($projectId);
|
||||
*
|
||||
* 4. 工具方法
|
||||
* - 清空索引: clear();
|
||||
*/
|
||||
class SeekDBProject
|
||||
{
|
||||
/**
|
||||
* 搜索项目(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param int $userid 用户ID(权限过滤)
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $limit 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $limit = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::projectFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
$embedding = self::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::projectFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::projectVectorSearch($embedding, $userid, $limit)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
$embedding = self::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::projectHybridSearch($keyword, $embedding, $userid, $limit)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SeekDB project search error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文本的 Embedding 向量
|
||||
*
|
||||
* @param string $text 文本
|
||||
* @return array 向量数组(空数组表示失败)
|
||||
*/
|
||||
private static function getEmbedding(string $text): array
|
||||
{
|
||||
if (empty($text)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$result = AI::getEmbedding($text);
|
||||
if (Base::isSuccess($result)) {
|
||||
return $result['data'] ?? [];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Get embedding error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results SeekDB 返回的结果
|
||||
* @return array 格式化后的结果
|
||||
*/
|
||||
private static function formatSearchResults(array $results): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($results as $item) {
|
||||
$formatted[] = [
|
||||
'project_id' => $item['project_id'],
|
||||
'id' => $item['project_id'],
|
||||
'userid' => $item['userid'],
|
||||
'personal' => $item['personal'],
|
||||
'name' => $item['project_name'],
|
||||
'desc_preview' => $item['project_desc_preview'] ?? null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个项目到 SeekDB
|
||||
*
|
||||
* @param Project $project 项目模型
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(Project $project): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 已归档的项目不索引
|
||||
if ($project->archived_at) {
|
||||
return self::delete($project->id);
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($project);
|
||||
|
||||
// 获取 embedding(如果 AI 可用)
|
||||
$embedding = null;
|
||||
if (!empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = self::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 SeekDB
|
||||
$result = SeekDBBase::upsertProjectVector([
|
||||
'project_id' => $project->id,
|
||||
'userid' => $project->userid ?? 0,
|
||||
'personal' => $project->personal ?? 0,
|
||||
'project_name' => $project->name ?? '',
|
||||
'project_desc' => $project->desc ?? '',
|
||||
'content_vector' => $embedding,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SeekDB project sync error: ' . $e->getMessage(), [
|
||||
'project_id' => $project->id,
|
||||
'project_name' => $project->name,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可搜索的文本内容
|
||||
*
|
||||
* @param Project $project 项目模型
|
||||
* @return string 可搜索的文本
|
||||
*/
|
||||
private static function buildSearchableContent(Project $project): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!empty($project->name)) {
|
||||
$parts[] = $project->name;
|
||||
}
|
||||
if (!empty($project->desc)) {
|
||||
$parts[] = $project->desc;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步项目
|
||||
*
|
||||
* @param iterable $projects 项目列表
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $projects): int
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($projects as $project) {
|
||||
if (self::sync($project)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目索引
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $projectId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 删除项目索引
|
||||
SeekDBBase::deleteProjectVector($projectId);
|
||||
// 删除项目成员关系
|
||||
SeekDBBase::deleteAllProjectUsers($projectId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SeekDBBase::clearAllProjectVectors();
|
||||
SeekDBBase::clearAllProjectUsers();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引项目数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return SeekDBBase::getIndexedProjectCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 成员关系方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 添加项目成员到 SeekDB
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @param int $userid 用户ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function addProjectUser(int $projectId, int $userid): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb") || $projectId <= 0 || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SeekDBBase::upsertProjectUser($projectId, $userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目成员
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @param int $userid 用户ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function removeProjectUser(int $projectId, int $userid): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb") || $projectId <= 0 || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SeekDBBase::deleteProjectUser($projectId, $userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步项目的所有成员到 SeekDB
|
||||
*
|
||||
* @param int $projectId 项目ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function syncProjectUsers(int $projectId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb") || $projectId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 从 MySQL 获取项目成员
|
||||
$userids = ProjectUser::where('project_id', $projectId)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
|
||||
// 同步到 SeekDB
|
||||
return SeekDBBase::syncProjectUsers($projectId, $userids);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SeekDB syncProjectUsers error: ' . $e->getMessage(), ['project_id' => $projectId]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步所有项目成员关系(全量同步)
|
||||
*
|
||||
* @param callable|null $progressCallback 进度回调
|
||||
* @return int 同步数量
|
||||
*/
|
||||
public static function syncAllProjectUsers(?callable $progressCallback = null): int
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$lastId = 0;
|
||||
$batchSize = 1000;
|
||||
|
||||
// 先清空 SeekDB 中的 project_users 表
|
||||
SeekDBBase::clearAllProjectUsers();
|
||||
|
||||
// 分批同步
|
||||
while (true) {
|
||||
$records = ProjectUser::where('id', '>', $lastId)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
SeekDBBase::upsertProjectUser($record->project_id, $record->userid);
|
||||
$count++;
|
||||
$lastId = $record->id;
|
||||
}
|
||||
|
||||
if ($progressCallback) {
|
||||
$progressCallback($count);
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
|
||||
571
app/Module/SeekDB/SeekDBTask.php
Normal file
571
app/Module/SeekDB/SeekDBTask.php
Normal file
@@ -0,0 +1,571 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\SeekDB;
|
||||
|
||||
use App\Models\ProjectTask;
|
||||
use App\Models\ProjectTaskContent;
|
||||
use App\Models\ProjectTaskUser;
|
||||
use App\Models\ProjectTaskVisibilityUser;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* SeekDB 任务搜索类
|
||||
*
|
||||
* 权限逻辑说明:
|
||||
* - visibility = 1: 项目人员可见,通过 project_users 表过滤
|
||||
* - visibility = 2: 任务人员可见,通过 task_users 表过滤(ProjectTaskUser)
|
||||
* - visibility = 3: 指定成员可见,通过 task_users 表过滤(ProjectTaskUser + ProjectTaskVisibilityUser)
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索任务: search($userid, $keyword, $searchType, $limit);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(ProjectTask $task);
|
||||
* - 批量同步: batchSync($tasks);
|
||||
* - 删除索引: delete($taskId);
|
||||
*
|
||||
* 3. 成员关系方法
|
||||
* - 添加成员: addTaskUser($taskId, $userid);
|
||||
* - 删除成员: removeTaskUser($taskId, $userid);
|
||||
* - 同步所有成员: syncTaskUsers($taskId);
|
||||
*
|
||||
* 4. 工具方法
|
||||
* - 清空索引: clear();
|
||||
*/
|
||||
class SeekDBTask
|
||||
{
|
||||
/**
|
||||
* 最大内容长度(字符)
|
||||
*/
|
||||
public const MAX_CONTENT_LENGTH = 50000; // 50K 字符
|
||||
|
||||
/**
|
||||
* 搜索任务(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param int $userid 用户ID(权限过滤)
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $limit 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $limit = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::taskFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
$embedding = self::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::taskFullTextSearch($keyword, $userid, $limit, 0)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::taskVectorSearch($embedding, $userid, $limit)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
$embedding = self::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::taskHybridSearch($keyword, $embedding, $userid, $limit)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SeekDB task search error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文本的 Embedding 向量
|
||||
*
|
||||
* @param string $text 文本
|
||||
* @return array 向量数组(空数组表示失败)
|
||||
*/
|
||||
private static function getEmbedding(string $text): array
|
||||
{
|
||||
if (empty($text)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$result = AI::getEmbedding($text);
|
||||
if (Base::isSuccess($result)) {
|
||||
return $result['data'] ?? [];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Get embedding error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results SeekDB 返回的结果
|
||||
* @return array 格式化后的结果
|
||||
*/
|
||||
private static function formatSearchResults(array $results): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($results as $item) {
|
||||
$formatted[] = [
|
||||
'task_id' => $item['task_id'],
|
||||
'id' => $item['task_id'],
|
||||
'project_id' => $item['project_id'],
|
||||
'userid' => $item['userid'],
|
||||
'visibility' => $item['visibility'],
|
||||
'name' => $item['task_name'],
|
||||
'desc_preview' => $item['task_desc_preview'] ?? null,
|
||||
'content_preview' => $item['task_content_preview'] ?? null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个任务到 SeekDB
|
||||
*
|
||||
* @param ProjectTask $task 任务模型
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(ProjectTask $task): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 已归档或已删除的任务不索引
|
||||
if ($task->archived_at || $task->deleted_at) {
|
||||
return self::delete($task->id);
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取任务详细内容
|
||||
$taskContent = self::getTaskContent($task);
|
||||
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($task, $taskContent);
|
||||
|
||||
// 获取 embedding(如果 AI 可用)
|
||||
$embedding = null;
|
||||
if (!empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = self::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 SeekDB
|
||||
$result = SeekDBBase::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,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SeekDB task sync error: ' . $e->getMessage(), [
|
||||
'task_id' => $task->id,
|
||||
'task_name' => $task->name,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详细内容
|
||||
*
|
||||
* @param ProjectTask $task 任务模型
|
||||
* @return string 任务内容
|
||||
*/
|
||||
private static function getTaskContent(ProjectTask $task): string
|
||||
{
|
||||
try {
|
||||
$content = ProjectTaskContent::where('task_id', $task->id)->first();
|
||||
if (!$content) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 解析内容
|
||||
$contentData = Base::json2array($content->content);
|
||||
$text = '';
|
||||
|
||||
// 提取文本内容(内容可能是 blocks 格式)
|
||||
if (is_array($contentData)) {
|
||||
$text = self::extractTextFromContent($contentData);
|
||||
} elseif (is_string($contentData)) {
|
||||
$text = $contentData;
|
||||
}
|
||||
|
||||
// 限制内容长度
|
||||
return mb_substr($text, 0, self::MAX_CONTENT_LENGTH);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Get task content error: ' . $e->getMessage(), ['task_id' => $task->id]);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从内容数组中提取文本
|
||||
*
|
||||
* @param array $contentData 内容数据
|
||||
* @return string 提取的文本
|
||||
*/
|
||||
private static function extractTextFromContent(array $contentData): string
|
||||
{
|
||||
$texts = [];
|
||||
|
||||
// 处理 blocks 格式
|
||||
if (isset($contentData['blocks']) && is_array($contentData['blocks'])) {
|
||||
foreach ($contentData['blocks'] as $block) {
|
||||
if (isset($block['text'])) {
|
||||
$texts[] = $block['text'];
|
||||
}
|
||||
if (isset($block['data']['text'])) {
|
||||
$texts[] = $block['data']['text'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理其他格式
|
||||
if (isset($contentData['text'])) {
|
||||
$texts[] = $contentData['text'];
|
||||
}
|
||||
|
||||
return implode(' ', $texts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可搜索的文本内容
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步任务
|
||||
*
|
||||
* @param iterable $tasks 任务列表
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $tasks): int
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($tasks as $task) {
|
||||
if (self::sync($task)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除任务索引
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $taskId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 删除任务索引
|
||||
SeekDBBase::deleteTaskVector($taskId);
|
||||
// 删除任务成员关系
|
||||
SeekDBBase::deleteAllTaskUsers($taskId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务可见性
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $visibility 可见性
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function updateVisibility(int $taskId, int $visibility): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb") || $taskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SeekDBBase::updateTaskVisibility($taskId, $visibility);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SeekDBBase::clearAllTaskVectors();
|
||||
SeekDBBase::clearAllTaskUsers();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引任务数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return SeekDBBase::getIndexedTaskCount();
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 成员关系方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 添加任务成员到 SeekDB
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $userid 用户ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function addTaskUser(int $taskId, int $userid): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb") || $taskId <= 0 || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SeekDBBase::upsertTaskUser($taskId, $userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除任务成员
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $userid 用户ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function removeTaskUser(int $taskId, int $userid): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb") || $taskId <= 0 || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SeekDBBase::deleteTaskUser($taskId, $userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定可见成员(visibility=3 场景)
|
||||
*
|
||||
* 特殊处理:需要检查该用户是否仍是任务的负责人/协作人
|
||||
* 如果是,则不应该从 task_users 中删除
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @param int $userid 用户ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function removeVisibilityUser(int $taskId, int $userid): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb") || $taskId <= 0 || $userid <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查用户是否仍是任务的负责人/协作人
|
||||
$isTaskMember = ProjectTaskUser::where('task_id', $taskId)
|
||||
->where('userid', $userid)
|
||||
->exists();
|
||||
|
||||
// 检查是否是父任务的成员(子任务场景)
|
||||
$task = \App\Models\ProjectTask::find($taskId);
|
||||
$isParentTaskMember = false;
|
||||
if ($task && $task->parent_id > 0) {
|
||||
$isParentTaskMember = ProjectTaskUser::where('task_id', $task->parent_id)
|
||||
->where('userid', $userid)
|
||||
->exists();
|
||||
}
|
||||
|
||||
// 如果仍是任务成员,不删除
|
||||
if ($isTaskMember || $isParentTaskMember) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 从 SeekDB 删除
|
||||
return SeekDBBase::deleteTaskUser($taskId, $userid);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SeekDB removeVisibilityUser error: ' . $e->getMessage(), [
|
||||
'task_id' => $taskId,
|
||||
'userid' => $userid,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步任务的所有成员到 SeekDB
|
||||
*
|
||||
* 包括:ProjectTaskUser 和 ProjectTaskVisibilityUser
|
||||
*
|
||||
* @param int $taskId 任务ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function syncTaskUsers(int $taskId): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb") || $taskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取任务成员(负责人/协作人)
|
||||
$taskUserIds = ProjectTaskUser::where('task_id', $taskId)
|
||||
->orWhere('task_pid', $taskId)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
|
||||
// 获取可见性指定成员
|
||||
$visibilityUserIds = ProjectTaskVisibilityUser::where('task_id', $taskId)
|
||||
->pluck('userid')
|
||||
->toArray();
|
||||
|
||||
// 合并去重
|
||||
$allUserIds = array_unique(array_merge($taskUserIds, $visibilityUserIds));
|
||||
|
||||
// 同步到 SeekDB
|
||||
return SeekDBBase::syncTaskUsers($taskId, $allUserIds);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SeekDB syncTaskUsers error: ' . $e->getMessage(), ['task_id' => $taskId]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步所有任务成员关系(全量同步)
|
||||
*
|
||||
* @param callable|null $progressCallback 进度回调
|
||||
* @return int 同步数量
|
||||
*/
|
||||
public static function syncAllTaskUsers(?callable $progressCallback = null): int
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$lastId = 0;
|
||||
$batchSize = 1000;
|
||||
|
||||
// 先清空 SeekDB 中的 task_users 表
|
||||
SeekDBBase::clearAllTaskUsers();
|
||||
|
||||
// 同步 ProjectTaskUser
|
||||
while (true) {
|
||||
$records = ProjectTaskUser::where('id', '>', $lastId)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
SeekDBBase::upsertTaskUser($record->task_id, $record->userid);
|
||||
// 如果有父任务,也添加到父任务
|
||||
if ($record->task_pid) {
|
||||
SeekDBBase::upsertTaskUser($record->task_pid, $record->userid);
|
||||
}
|
||||
$count++;
|
||||
$lastId = $record->id;
|
||||
}
|
||||
|
||||
if ($progressCallback) {
|
||||
$progressCallback($count);
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 ProjectTaskVisibilityUser
|
||||
$lastId = 0;
|
||||
while (true) {
|
||||
$records = ProjectTaskVisibilityUser::where('id', '>', $lastId)
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
SeekDBBase::upsertTaskUser($record->task_id, $record->userid);
|
||||
$count++;
|
||||
$lastId = $record->id;
|
||||
}
|
||||
|
||||
if ($progressCallback) {
|
||||
$progressCallback($count);
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
|
||||
275
app/Module/SeekDB/SeekDBUser.php
Normal file
275
app/Module/SeekDB/SeekDBUser.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Module\SeekDB;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Module\Apps;
|
||||
use App\Module\Base;
|
||||
use App\Module\AI;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* SeekDB 用户搜索类(联系人搜索)
|
||||
*
|
||||
* 使用方法:
|
||||
*
|
||||
* 1. 搜索方法
|
||||
* - 搜索用户: search($keyword, $searchType, $limit);
|
||||
*
|
||||
* 2. 同步方法
|
||||
* - 单个同步: sync(User $user);
|
||||
* - 批量同步: batchSync($users);
|
||||
* - 删除索引: delete($userid);
|
||||
*
|
||||
* 3. 工具方法
|
||||
* - 清空索引: clear();
|
||||
*/
|
||||
class SeekDBUser
|
||||
{
|
||||
/**
|
||||
* 搜索用户(支持全文、向量、混合搜索)
|
||||
*
|
||||
* @param string $keyword 搜索关键词
|
||||
* @param string $searchType 搜索类型: text/vector/hybrid
|
||||
* @param int $limit 返回数量
|
||||
* @return array 搜索结果
|
||||
*/
|
||||
public static function search(string $keyword, string $searchType = 'hybrid', int $limit = 20): array
|
||||
{
|
||||
if (empty($keyword)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($searchType) {
|
||||
case 'text':
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::userFullTextSearch($keyword, $limit, 0)
|
||||
);
|
||||
|
||||
case 'vector':
|
||||
$embedding = self::getEmbedding($keyword);
|
||||
if (empty($embedding)) {
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::userFullTextSearch($keyword, $limit, 0)
|
||||
);
|
||||
}
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::userVectorSearch($embedding, $limit)
|
||||
);
|
||||
|
||||
case 'hybrid':
|
||||
default:
|
||||
$embedding = self::getEmbedding($keyword);
|
||||
return self::formatSearchResults(
|
||||
SeekDBBase::userHybridSearch($keyword, $embedding, $limit)
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SeekDB user search error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文本的 Embedding 向量
|
||||
*
|
||||
* @param string $text 文本
|
||||
* @return array 向量数组(空数组表示失败)
|
||||
*/
|
||||
private static function getEmbedding(string $text): array
|
||||
{
|
||||
if (empty($text)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$result = AI::getEmbedding($text);
|
||||
if (Base::isSuccess($result)) {
|
||||
return $result['data'] ?? [];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('Get embedding error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化搜索结果
|
||||
*
|
||||
* @param array $results SeekDB 返回的结果
|
||||
* @return array 格式化后的结果
|
||||
*/
|
||||
private static function formatSearchResults(array $results): array
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($results as $item) {
|
||||
$formatted[] = [
|
||||
'userid' => $item['userid'],
|
||||
'nickname' => $item['nickname'],
|
||||
'email' => $item['email'],
|
||||
'tel' => $item['tel'],
|
||||
'profession' => $item['profession'],
|
||||
'introduction_preview' => $item['introduction_preview'] ?? null,
|
||||
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 同步方法
|
||||
// ==============================
|
||||
|
||||
/**
|
||||
* 同步单个用户到 SeekDB
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function sync(User $user): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 不处理机器人账号
|
||||
if ($user->bot) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 不处理已禁用的账号
|
||||
if ($user->disable_at) {
|
||||
return self::delete($user->userid);
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建用于搜索的文本内容
|
||||
$searchableContent = self::buildSearchableContent($user);
|
||||
|
||||
// 获取 embedding(如果 AI 可用)
|
||||
$embedding = null;
|
||||
if (!empty($searchableContent) && Apps::isInstalled('ai')) {
|
||||
$embeddingResult = self::getEmbedding($searchableContent);
|
||||
if (!empty($embeddingResult)) {
|
||||
$embedding = '[' . implode(',', $embeddingResult) . ']';
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 SeekDB
|
||||
$result = SeekDBBase::upsertUserVector([
|
||||
'userid' => $user->userid,
|
||||
'nickname' => $user->nickname ?? '',
|
||||
'email' => $user->email ?? '',
|
||||
'tel' => $user->tel ?? '',
|
||||
'profession' => $user->profession ?? '',
|
||||
'introduction' => $user->introduction ?? '',
|
||||
'content_vector' => $embedding,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('SeekDB user sync error: ' . $e->getMessage(), [
|
||||
'userid' => $user->userid,
|
||||
'nickname' => $user->nickname,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建可搜索的文本内容
|
||||
*
|
||||
* @param User $user 用户模型
|
||||
* @return string 可搜索的文本
|
||||
*/
|
||||
private static function buildSearchableContent(User $user): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (!empty($user->nickname)) {
|
||||
$parts[] = $user->nickname;
|
||||
}
|
||||
if (!empty($user->email)) {
|
||||
$parts[] = $user->email;
|
||||
}
|
||||
if (!empty($user->profession)) {
|
||||
$parts[] = $user->profession;
|
||||
}
|
||||
if (!empty($user->introduction)) {
|
||||
$parts[] = $user->introduction;
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步用户
|
||||
*
|
||||
* @param iterable $users 用户列表
|
||||
* @return int 成功同步的数量
|
||||
*/
|
||||
public static function batchSync(iterable $users): int
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($users as $user) {
|
||||
if (self::sync($user)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户索引
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function delete(int $userid): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SeekDBBase::deleteUserVector($userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有索引
|
||||
*
|
||||
* @return bool 是否成功
|
||||
*/
|
||||
public static function clear(): bool
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SeekDBBase::clearAllUserVectors();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已索引用户数量
|
||||
*
|
||||
* @return int 数量
|
||||
*/
|
||||
public static function getIndexedCount(): int
|
||||
{
|
||||
if (!Apps::isInstalled("seekdb")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return SeekDBBase::getIndexedUserCount();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user