360 lines
14 KiB
PHP
360 lines
14 KiB
PHP
<?php
|
|
|
|
namespace app\common\service\ai;
|
|
|
|
use app\common\model\ai\AiChatMessage;
|
|
use app\common\model\ai\AiChatSession;
|
|
use app\common\model\ai\AiConfig;
|
|
use app\common\model\ai\AiLog;
|
|
use think\facade\Cache;
|
|
|
|
class AiAssistantService
|
|
{
|
|
public const AI_LOG_TYPE_ASSISTANT = 7;
|
|
|
|
public static function chat(string $message, int $userId, string $clientId, int $sessionId = 0, string $ip = ''): array
|
|
{
|
|
$message = trim(mb_substr($message, 0, 1000));
|
|
if ($message === '') {
|
|
return ['success' => 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 助手,面向普通用户回答站内体育资讯、赛事、社区、彩票和加密行情相关问题。'
|
|
. '必须优先依据提供的站内资料和实时上下文回答;资料不足时要直接说明不足并建议用户换关键词。'
|
|
. '不要声称可以访问后台、源码、服务器或未提供的内部数据。'
|
|
. '回答使用中文,结构清晰,语气专业克制。涉及赛事预测、彩票或加密行情时,必须说明仅供参考,不构成投资或购彩建议。';
|
|
}
|
|
}
|