false, 'error' => '请输入要咨询的问题']; } if ($userId <= 0 && $clientId === '') { return ['success' => false, 'error' => '缺少会话标识']; } if (!self::checkRateLimit($userId, $clientId, $ip)) { return ['success' => false, 'error' => '提问太频繁,请稍后再试']; } $session = self::resolveSession($sessionId, $userId, $clientId, $message); if (!$session) { return ['success' => false, 'error' => '会话不存在或无权访问']; } $history = self::historyMessages((int) $session->id); $context = AiAssistantContextService::build($message, $userId, (int) $session->id); AiChatMessage::create([ 'session_id' => (int) $session->id, 'user_id' => $userId, 'client_id' => $clientId, 'role' => AiChatMessage::ROLE_USER, 'content' => $message, 'sources_json' => '[]', 'status' => AiChatMessage::STATUS_SUCCESS, 'create_time' => time(), 'update_time' => time(), ]); $messages = self::buildModelMessages($history, $message, $context); $result = self::callAssistantModel($messages); $status = !empty($result['success']) ? AiChatMessage::STATUS_SUCCESS : AiChatMessage::STATUS_FAILED; $answer = !empty($result['success']) ? trim((string) ($result['content'] ?? '')) : 'AI助手暂时没有生成成功,请稍后再试。'; if ($answer === '') { $answer = '我暂时没有找到足够的站内依据,请换个关键词再问一次。'; } $assistantMessage = AiChatMessage::create([ 'session_id' => (int) $session->id, 'user_id' => $userId, 'client_id' => $clientId, 'role' => AiChatMessage::ROLE_ASSISTANT, 'content' => $answer, 'sources_json' => json_encode($context['sources'] ?? [], JSON_UNESCAPED_UNICODE), 'model' => (string) ($result['model'] ?? self::defaultModelOptions()['model']), 'tokens_used' => (int) ($result['tokens'] ?? 0), 'cost_ms' => (int) ($result['cost_ms'] ?? 0), 'status' => $status, 'error_msg' => mb_substr((string) ($result['error'] ?? ''), 0, 500), 'create_time' => time(), 'update_time' => time(), ]); self::touchSession((int) $session->id, $message, $answer); self::recordAiLog($userId, (int) $session->id, $result, $status); if (empty($result['success'])) { return ['success' => false, 'error' => self::sanitizeModelError((string) ($result['error'] ?? ''))]; } $session = AiChatSession::findOrEmpty((int) $session->id); return [ 'success' => true, 'data' => [ 'session' => self::formatSession($session->toArray()), 'message' => self::formatMessage($assistantMessage->toArray()), 'sources' => $context['sources'] ?? [], ], ]; } public static function sessions(int $userId, string $clientId): array { if ($userId <= 0 && $clientId === '') { return []; } $query = AiChatSession::order('last_active_time desc')->order('id desc')->limit(30); self::applyOwnerWhere($query, $userId, $clientId); return array_map([self::class, 'formatSession'], $query->select()->toArray()); } public static function messages(int $sessionId, int $userId, string $clientId): array { $session = self::getOwnedSession($sessionId, $userId, $clientId); if (!$session) { return ['success' => false, 'error' => '会话不存在或无权访问']; } $messages = AiChatMessage::where('session_id', $sessionId) ->order('id', 'asc') ->limit(200) ->select() ->toArray(); return [ 'success' => true, 'data' => [ 'session' => self::formatSession($session->toArray()), 'messages' => array_map([self::class, 'formatMessage'], $messages), ], ]; } public static function clear(int $sessionId, int $userId, string $clientId): array { if ($sessionId > 0) { $session = self::getOwnedSession($sessionId, $userId, $clientId); if (!$session) { return ['success' => false, 'error' => '会话不存在或无权访问']; } AiChatMessage::where('session_id', $sessionId)->delete(); $session->delete(); return ['success' => true]; } if ($userId <= 0 && $clientId === '') { return ['success' => false, 'error' => '缺少会话标识']; } $query = AiChatSession::field('id'); self::applyOwnerWhere($query, $userId, $clientId); $ids = $query->column('id'); if (!empty($ids)) { AiChatMessage::whereIn('session_id', $ids)->delete(); AiChatSession::whereIn('id', $ids)->delete(); } return ['success' => true]; } private static function resolveSession(int $sessionId, int $userId, string $clientId, string $message): ?AiChatSession { if ($sessionId > 0) { return self::getOwnedSession($sessionId, $userId, $clientId); } $now = time(); return AiChatSession::create([ 'user_id' => $userId, 'client_id' => $clientId, 'title' => self::makeTitle($message), 'last_message' => $message, 'last_active_time' => $now, 'message_count' => 0, 'create_time' => $now, 'update_time' => $now, ]); } private static function getOwnedSession(int $sessionId, int $userId, string $clientId): ?AiChatSession { $query = AiChatSession::where('id', $sessionId); self::applyOwnerWhere($query, $userId, $clientId); $session = $query->findOrEmpty(); return $session->isEmpty() ? null : $session; } private static function applyOwnerWhere($query, int $userId, string $clientId): void { if ($userId > 0) { $query->where('user_id', $userId); return; } $query->where('user_id', 0)->where('client_id', $clientId); } private static function historyMessages(int $sessionId): array { $limit = max(2, min(20, (int) AiConfig::getVal('assistant_history_limit', '8'))); $rows = AiChatMessage::where('session_id', $sessionId) ->where('status', AiChatMessage::STATUS_SUCCESS) ->order('id', 'desc') ->limit($limit) ->select() ->toArray(); return array_reverse($rows); } private static function buildModelMessages(array $history, string $message, array $context): array { $messages = [ [ 'role' => 'system', 'content' => AiConfig::getVal('assistant_system_prompt', self::defaultSystemPrompt()), ], ]; foreach ($history as $row) { $role = $row['role'] === AiChatMessage::ROLE_ASSISTANT ? 'assistant' : 'user'; $content = trim((string) ($row['content'] ?? '')); if ($content !== '') { $messages[] = ['role' => $role, 'content' => mb_substr($content, 0, 1000)]; } } $messages[] = [ 'role' => 'user', 'content' => "用户问题:\n{$message}\n\n站内资料与实时上下文:\n" . ($context['context_text'] ?? '{}') . "\n\n请按照“直接结论、依据摘要、相关内容、风险提示”的结构回答。" . "如果站内资料不足,请明确说明,不要编造。彩票、赛事预测、加密行情必须提示不构成投资或购彩建议。", ]; return $messages; } private static function callAssistantModel(array $messages): array { $client = new DeepSeekClient(); $optionsList = [self::defaultModelOptions()]; $failedErrors = []; $lastResult = ['success' => false, 'error' => 'AI助手模型服务暂时不可用', 'tokens' => 0]; foreach ($optionsList as $options) { $result = $client->chatMessages($messages, $options); if (!empty($result['success'])) { return $result; } $lastResult = $result; $provider = (string) ($result['provider_label'] ?? ($options['provider_label'] ?? 'AI模型')); $failedErrors[] = $provider . ': ' . (string) ($result['error'] ?? '调用失败'); } if (!empty($failedErrors)) { $lastResult['error'] = implode(' | ', $failedErrors); } return $lastResult; } private static function sanitizeModelError(string $error): string { $error = trim($error); if ($error === '') { return 'AI助手模型调用失败:未知错误'; } $error = preg_replace('/Bearer\s+[A-Za-z0-9._\-]+/i', 'Bearer ***', $error) ?: $error; $error = preg_replace('/sk-[A-Za-z0-9._\-]{8,}/i', 'sk-***', $error) ?: $error; return mb_substr($error, 0, 500); } private static function defaultModelOptions(): array { return DeepSeekClient::defaultModelOptions([ 'max_tokens' => 1600, 'temperature' => 0.45, ]); } private static function checkRateLimit(int $userId, string $clientId, string $ip): bool { $limit = max(1, (int) AiConfig::getVal('assistant_rate_limit_per_minute', '10')); $identity = $userId > 0 ? 'u' . $userId : 'c' . md5($clientId . '|' . $ip); $key = 'assistant_rate_' . $identity . '_' . date('YmdHi'); $count = (int) Cache::get($key); if ($count >= $limit) { return false; } Cache::set($key, $count + 1, 70); return true; } private static function touchSession(int $sessionId, string $question, string $answer): void { AiChatSession::where('id', $sessionId)->update([ 'last_message' => mb_substr($question, 0, 500), 'last_answer' => mb_substr($answer, 0, 500), 'last_active_time' => time(), 'message_count' => AiChatMessage::where('session_id', $sessionId)->count(), 'update_time' => time(), ]); } private static function recordAiLog(int $userId, int $sessionId, array $result, int $status): void { AiLog::create([ 'user_id' => $userId, 'type' => self::AI_LOG_TYPE_ASSISTANT, 'ref_id' => $sessionId, 'tokens_used' => (int) ($result['tokens'] ?? 0), 'cost_ms' => (int) ($result['cost_ms'] ?? 0), 'status' => $status === AiChatMessage::STATUS_SUCCESS ? 1 : 2, 'create_time' => time(), ]); } private static function formatSession(array $row): array { return [ 'id' => (int) ($row['id'] ?? 0), 'title' => (string) ($row['title'] ?? ''), 'last_message' => (string) ($row['last_message'] ?? ''), 'last_answer' => (string) ($row['last_answer'] ?? ''), 'message_count' => (int) ($row['message_count'] ?? 0), 'last_active_time' => self::formatTime($row['last_active_time'] ?? 0), 'create_time' => self::formatTime($row['create_time'] ?? 0), ]; } private static function formatMessage(array $row): array { $sources = json_decode((string) ($row['sources_json'] ?? '[]'), true); return [ 'id' => (int) ($row['id'] ?? 0), 'session_id' => (int) ($row['session_id'] ?? 0), 'role' => (string) ($row['role'] ?? ''), 'content' => (string) ($row['content'] ?? ''), 'sources' => is_array($sources) ? $sources : [], 'status' => (int) ($row['status'] ?? 1), 'error_msg' => (string) ($row['error_msg'] ?? ''), 'create_time' => self::formatTime($row['create_time'] ?? 0), ]; } private static function formatTime($value): string { if (is_numeric($value) && (int) $value > 0) { return date('Y-m-d H:i:s', (int) $value); } return (string) ($value ?: ''); } private static function makeTitle(string $message): string { $title = trim(preg_replace('/\s+/u', ' ', $message) ?: ''); return mb_substr($title ?: '新的对话', 0, 24); } private static function defaultSystemPrompt(): string { return '你是世博头条 AI 助手,面向普通用户回答站内体育资讯、赛事、社区、彩票和加密行情相关问题。' . '必须优先依据提供的站内资料和实时上下文回答;资料不足时要直接说明不足并建议用户换关键词。' . '不要声称可以访问后台、源码、服务器或未提供的内部数据。' . '回答使用中文,结构清晰,语气专业克制。涉及赛事预测、彩票或加密行情时,必须说明仅供参考,不构成投资或购彩建议。'; } }