Files
ars-backend/app/Services/ChatService.php
ROOG e956df9daa main: 增强工具功能与消息处理
- 添加 `FileReadTool`,支持文件内容读取与安全验证
- 引入 `hasToolMessages` 逻辑,优化工具历史上下文处理
- 修改工具选项逻辑,支持禁用工具时的动态调整
- 增加消息序列化逻辑,优化 Redis 序列管理与数据同步
- 扩展测试覆盖,验证序列化与工具调用场景
- 增强 Docker Compose 脚本,支持应用重置与日志清理
- 调整工具调用超时设置,提升运行时用户体验
2025-12-24 00:55:54 +08:00

355 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services;
use App\Enums\ChatSessionStatus;
use App\Exceptions\ChatSessionStatusException;
use App\Models\ChatSession;
use App\Models\Message;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
class ChatService
{
public function __construct(private readonly MessageSequence $messageSequence)
{
}
/**
* 创建一个新聊天会话。
* @param string|null $name
* @return ChatSession
*/
public function createSession(string $name = null): ChatSession
{
return ChatSession::create([
'session_id' => (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<string, mixed> $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, bool $save = true): Message
{
$messageRef = null;
$isNew = false;
$wasDeduped = false;
DB::transaction(function () use ($dto, &$messageRef, &$isNew, &$wasDeduped, $save) {
/** @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;
}
}
$attempts = 0;
while (true) {
$attempts++;
$newSeq = $this->messageSequence->nextForSession($session);
$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 {
if ($save) {
$message->save();
}
$isNew = true;
break;
} 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;
}
}
if ($this->isUniqueConstraint($e) && $this->isSeqUniqueConstraint($e) && $attempts < 3) {
$maxPersistedSeq = (int) (Message::query()
->where('session_id', $session->session_id)
->max('seq') ?? 0);
$this->messageSequence->syncToAtLeast($session->session_id, max($session->last_seq, $maxPersistedSeq));
continue;
}
throw $e;
}
}
$session->update([
'last_seq' => $newSeq,
'last_message_id' => $message->message_id,
'updated_at' => now(),
]);
$messageRef = $message;
if ($isNew && $save) {
DB::afterCommit(fn () => $this->publishMessageAppended($message));
} else {
$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 {
//todo::优化这里。
Redis::publish($channel, json_encode($message->toArray(), JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_IGNORE));
} 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';
}
private function isSeqUniqueConstraint(QueryException $e): bool
{
$details = $e->errorInfo[2] ?? $e->getMessage();
return is_string($details) && str_contains($details, 'messages_session_id_seq_unique');
}
}