Files
sbnews/server/app/common/service/ai/AiAssistantService.php

366 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);
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(),
]);
$orchestrated = AiAssistantOrchestrator::run($message, $history, $userId, (int) $session->id);
$result = is_array($orchestrated['result'] ?? null)
? $orchestrated['result']
: ['success' => false, 'error' => 'AI助手模型服务暂时不可用', 'tokens' => 0];
$status = !empty($result['success']) ? AiChatMessage::STATUS_SUCCESS : AiChatMessage::STATUS_FAILED;
$answer = trim((string) ($orchestrated['answer'] ?? ($result['content'] ?? '')));
if ($answer === '' && empty($result['success'])) {
$answer = 'AI助手暂时没有生成成功,请稍后再试。';
}
if ($answer === '') {
$answer = '我暂时没有找到足够的站内依据,请换个关键词再问一次。';
}
$sources = is_array($orchestrated['sources'] ?? null) ? $orchestrated['sources'] : [];
$assistantMessage = AiChatMessage::create([
'session_id' => (int) $session->id,
'user_id' => $userId,
'client_id' => $clientId,
'role' => AiChatMessage::ROLE_ASSISTANT,
'content' => $answer,
'sources_json' => json_encode($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' => $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,
'retry_times' => 3,
'retry_sleep_ms' => 1200,
]);
}
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 助手,面向普通用户回答站内体育资讯、赛事、社区、彩票和加密行情相关问题。'
. '必须优先依据提供的站内资料和实时上下文回答;资料不足时要直接说明不足并建议用户换关键词。'
. '不要声称可以访问后台、源码、服务器或未提供的内部数据。'
. '回答使用中文,结构清晰,语气专业克制。涉及赛事预测、彩票或加密行情时,必须说明仅供参考,不构成投资或购彩建议。';
}
}