(string) Str::uuid(), 'session_name' => $name, 'status' => ChatSessionStatus::OPEN, 'last_seq' => 0, ]); } /** * 根据会话ID获取聊天会话对象 * * @param string $sessionId 会话唯一标识符 * @return ChatSession 返回对应的聊天会话对象 * @throws ModelNotFoundException 当找不到指定会话时抛出异常 */ public function getSession(string $sessionId): ChatSession { return ChatSession::query()->whereKey($sessionId)->firstOrFail(); } /** * 将消息追加到指定的聊天会话中。 * * @param array $dto 包含消息详细信息的数据传输对象,包括以下键: * - session_id: 聊天会话的唯一标识符 * - role: 消息角色(如发件人或收件人) * - type: 消息类型 * - content: 消息内容(可选) * - payload: 附加信息(可选) * - reply_to: 被回复的消息 ID(可选) * - dedupe_key: 消息去重键(可选) * @param bool|null $wasDeduped 是否发生了去重(可选,按引用返回) * @return Message 返回成功追加的消息实例。如果存在去重键并已存在重复消息,则返回现有的消息。 */ public function appendMessage(array $dto, ?bool &$wasDeduped = null): Message { $messageRef = null; $isNew = false; $wasDeduped = false; DB::transaction(function () use ($dto, &$messageRef, &$isNew, &$wasDeduped) { /** @var ChatSession $session */ $session = ChatSession::query() ->whereKey($dto['session_id']) ->lockForUpdate() ->firstOrFail(); $this->ensureCanAppend($session, $dto['role'], $dto['type']); $dedupeKey = $dto['dedupe_key'] ?? null; if ($dedupeKey) { $existing = Message::query() ->where('session_id', $session->session_id) ->where('dedupe_key', $dedupeKey) ->first(); if ($existing) { $messageRef = $existing; $wasDeduped = true; return; } } $newSeq = $session->last_seq + 1; $message = new Message([ 'message_id' => (string) Str::uuid(), 'session_id' => $session->session_id, 'role' => $dto['role'], 'type' => $dto['type'], 'content' => $dto['content'] ?? null, 'payload' => $dto['payload'] ?? null, 'reply_to' => $dto['reply_to'] ?? null, 'dedupe_key' => $dedupeKey, 'seq' => $newSeq, 'created_at' => now(), ]); try { $message->save(); $isNew = true; } catch (QueryException $e) { if ($this->isUniqueConstraint($e) && $dedupeKey) { $existing = Message::query() ->where('session_id', $session->session_id) ->where('dedupe_key', $dedupeKey) ->first(); if ($existing) { $messageRef = $existing; $wasDeduped = true; return; } } throw $e; } $session->update([ 'last_seq' => $newSeq, 'last_message_id' => $message->message_id, 'updated_at' => now(), ]); $messageRef = $message; if ($isNew) { DB::afterCommit(fn () => $this->publishMessageAppended($message)); } }); /** @var Message $messageRef */ return $messageRef; } /** * 列出指定会话中指定序号之后的消息。 * * @param string $sessionId 会话唯一标识符 * @param int $afterSeq 指定序号之后的消息将被列出 * @param int $limit 最多返回的消息数量,默认为 50 * @return Collection 返回指定序号之后的消息列表 */ public function listMessagesBySeq(string $sessionId, int $afterSeq, int $limit = 50): Collection { $this->getSession($sessionId); // ensure exists return Message::query() ->where('session_id', $sessionId) ->where('seq', '>', $afterSeq) ->orderBy('seq') ->limit($limit) ->get(); } public function getSessionWithLastMessage(string $sessionId): ChatSession { /** @var ChatSession $session */ $session = $this->baseSessionQuery() ->where('chat_sessions.session_id', $sessionId) ->firstOrFail(); return $session; } public function archiveSession(string $sessionId): ChatSession { /** @var ChatSession $session */ $session = ChatSession::query()->whereKey($sessionId)->firstOrFail(); if ($session->status !== ChatSessionStatus::CLOSED) { $session->update([ 'status' => ChatSessionStatus::CLOSED, 'updated_at' => now(), ]); } return $this->getSessionWithLastMessage($sessionId); } public function getMessage(string $sessionId, string $messageId): ?Message { $message = Message::query()->where('message_id', $messageId)->first(); if (! $message || $message->session_id !== $sessionId) { return null; } return $message; } /** * 获取会话列表 * * @param array $filter 过滤条件数组,支持status状态过滤和q关键字搜索 * @param int $page 页码 * @param int $perPage 每页数量 * @return LengthAwarePaginator 分页结果 */ public function listSessions(array $filter, int $page, int $perPage): LengthAwarePaginator { // 构建基础查询 $query = $this->baseSessionQuery(); // 根据状态过滤会话 if (! empty($filter['status'])) { $query->where('chat_sessions.status', $filter['status']); } // 根据会话名称进行模糊搜索 if (! empty($filter['q'])) { $q = $filter['q']; $query->where('chat_sessions.session_name', 'ilike', '%'.$q.'%'); } // 按更新时间倒序排列 $query->orderByDesc('chat_sessions.updated_at'); // 执行分页查询 return $query->paginate($perPage, ['*'], 'page', $page); } /** * 更新聊天会话信息 * * @param string $sessionId 会话ID * @param array $patch 需要更新的字段数据 * @return ChatSession 更新后的聊天会话对象 * @throws ChatSessionStatusException 当尝试重新打开已关闭的会话时抛出异常 */ public function updateSession(string $sessionId, array $patch): ChatSession { // 查找包含最后一条消息的会话 $session = $this->findSessionWithLastMessage($sessionId); // 检查会话状态变更合法性:已关闭的会话不能重新打开 if (isset($patch['status']) && $session->status === ChatSessionStatus::CLOSED && $patch['status'] !== ChatSessionStatus::CLOSED) { throw new ChatSessionStatusException('Closed session cannot be reopened'); } // 填充更新数据并保存 $session->fill($patch); $session->updated_at = now(); $session->save(); // 返回更新后的会话信息 return $this->findSessionWithLastMessage($sessionId); } private function baseSessionQuery() { return ChatSession::query() ->leftJoin('messages as lm', 'lm.message_id', '=', 'chat_sessions.last_message_id') ->select('chat_sessions.*') ->addSelect([ 'lm.created_at as last_message_at', 'lm.content as last_message_content', 'lm.role as last_message_role', 'lm.type as last_message_type', ]); } private function findSessionWithLastMessage(string $sessionId): ChatSession { /** @var ChatSession $session */ $session = $this->baseSessionQuery() ->where('chat_sessions.session_id', $sessionId) ->firstOrFail(); return $session; } private function publishMessageAppended(Message $message): void { $root = Redis::getFacadeRoot(); $isMocked = $root instanceof \Mockery\MockInterface; if (! class_exists(\Redis::class) && ! $isMocked) { return; } $channel = "session:{$message->session_id}:messages"; try { Redis::publish($channel, $message->message_id); } catch (\Throwable $e) { logger()->warning('Redis publish failed', [ 'session_id' => $message->session_id, 'message_id' => $message->message_id, 'error' => $e->getMessage(), ]); } } private function ensureCanAppend(ChatSession $session, string $role, string $type): void { if ($session->status === ChatSessionStatus::CLOSED) { $allowed = $role === Message::ROLE_SYSTEM && in_array($type, ['run.status', 'error'], true); if (! $allowed) { throw new ChatSessionStatusException('Session is closed'); } } if ($session->status === ChatSessionStatus::LOCKED) { if ($role === Message::ROLE_USER && $type === 'user.prompt') { throw new ChatSessionStatusException('Session is locked'); } } } private function isUniqueConstraint(QueryException $e): bool { $sqlState = $e->getCode() ?: ($e->errorInfo[0] ?? null); return $sqlState === '23505'; } }