no message
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
<?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;
|
||||
private const QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode';
|
||||
private const QWEN_MODEL = 'qwen3.7-plus';
|
||||
|
||||
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'] ?? 'gpt-5.5'),
|
||||
'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' => 'AI助手暂时不可用,请稍后再试'];
|
||||
}
|
||||
|
||||
$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 = [];
|
||||
|
||||
$qwenOptions = self::qwenOptions();
|
||||
if (!empty($qwenOptions['api_key'])) {
|
||||
$optionsList[] = $qwenOptions;
|
||||
}
|
||||
$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 qwenOptions(): array
|
||||
{
|
||||
$apiKey = trim((string) AiConfig::getVal('qwen_api_key', ''));
|
||||
if ($apiKey === '') {
|
||||
$apiKey = trim((string) env('DASHSCOPE_API_KEY', ''));
|
||||
}
|
||||
if ($apiKey === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$baseUrl = trim((string) AiConfig::getVal('qwen_base_url', ''));
|
||||
if ($baseUrl === '') {
|
||||
$baseUrl = self::QWEN_BASE_URL;
|
||||
}
|
||||
$model = trim((string) AiConfig::getVal('qwen_model', ''));
|
||||
if ($model === '') {
|
||||
$model = self::QWEN_MODEL;
|
||||
}
|
||||
|
||||
return self::defaultModelOptions() + [
|
||||
'api_key' => $apiKey,
|
||||
'base_url' => $baseUrl,
|
||||
'model' => $model,
|
||||
'wire_api' => 'chat',
|
||||
'provider_label' => 'DashScope(Qwen)',
|
||||
];
|
||||
}
|
||||
|
||||
private static function defaultModelOptions(): array
|
||||
{
|
||||
return [
|
||||
'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 助手,面向普通用户回答站内体育资讯、赛事、社区、彩票和加密行情相关问题。'
|
||||
. '必须优先依据提供的站内资料和实时上下文回答;资料不足时要直接说明不足并建议用户换关键词。'
|
||||
. '不要声称可以访问后台、源码、服务器或未提供的内部数据。'
|
||||
. '回答使用中文,结构清晰,语气专业克制。涉及赛事预测、彩票或加密行情时,必须说明仅供参考,不构成投资或购彩建议。';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user