Files
chatroom/app/Http/Controllers/Admin/AiProviderController.php

398 lines
13 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
/**
* 文件功能AI 厂商配置管理控制器(管理后台)
*
* 提供完整的 AI 厂商 CRUD 管理功能,包括:
* - 列表/新增/编辑/删除 AI 厂商配置
* - 切换启用/禁用状态
* - 设置默认厂商(互斥,同时只有一个默认)
* - 全局开关控制机器人是否启用
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AiProviderConfig;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\View\View;
class AiProviderController extends Controller
{
/**
* 构造函数
*/
public function __construct(
private readonly ChatStateService $chatState,
) {}
/**
* 显示 AI 厂商配置列表页面
*
* 列出所有已配置的 AI 厂商,并显示全局开关状态。
*
* @return View AI 厂商管理页面
*/
public function index(): View
{
$providers = AiProviderConfig::orderBy('sort_order')->get();
$chatbotEnabled = Sysparam::getValue('chatbot_enabled', '0') === '1';
$chatbotMaxGold = Sysparam::getValue('chatbot_max_gold', '5000');
$chatbotMaxDailyRewards = Sysparam::getValue('chatbot_max_daily_rewards', '1');
return view('admin.ai-providers.index', compact('providers', 'chatbotEnabled', 'chatbotMaxGold', 'chatbotMaxDailyRewards'));
}
/**
* 保存全局设置
*/
public function updateSettings(Request $request): RedirectResponse
{
$data = $request->validate([
'chatbot_max_gold' => 'required|integer|min:1',
'chatbot_max_daily_rewards' => 'required|integer|min:1',
]);
Sysparam::updateOrCreate(
['alias' => 'chatbot_max_gold'],
[
'body' => (string) $data['chatbot_max_gold'],
'guidetxt' => '单次最高发放金币金额',
]
);
Sysparam::clearCache('chatbot_max_gold');
Sysparam::updateOrCreate(
['alias' => 'chatbot_max_daily_rewards'],
[
'body' => (string) $data['chatbot_max_daily_rewards'],
'guidetxt' => '每个用户单日最多获得金币次数',
]
);
Sysparam::clearCache('chatbot_max_daily_rewards');
return back()->with('success', '全局设置保存成功!');
}
/**
* 新增 AI 厂商配置
*
* @param Request $request 请求对象
* @return RedirectResponse 重定向到列表页
*/
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'provider' => 'required|string|max:50',
'name' => 'required|string|max:100',
'api_key' => 'required|string',
'api_endpoint' => 'required|url|max:255',
'model' => 'required|string|max:100',
'temperature' => 'nullable|numeric|min:0|max:2',
'max_tokens' => 'nullable|integer|min:100|max:32000',
'sort_order' => 'nullable|integer|min:0',
]);
// 加密 API Key
$data['api_key'] = Crypt::encryptString($data['api_key']);
$data['temperature'] = $data['temperature'] ?? 0.3;
$data['max_tokens'] = $data['max_tokens'] ?? 2048;
$data['sort_order'] = $data['sort_order'] ?? 0;
$data['is_enabled'] = true;
$data['is_default'] = false;
AiProviderConfig::create($data);
return redirect()->route('admin.ai-providers.index')
->with('success', '已成功添加 AI 厂商配置!');
}
/**
* 更新 AI 厂商配置
*
* @param Request $request 请求对象
* @param int $id 厂商配置 ID
* @return RedirectResponse 重定向到列表页
*/
public function update(Request $request, int $id): RedirectResponse
{
$provider = AiProviderConfig::findOrFail($id);
$data = $request->validate([
'provider' => 'required|string|max:50',
'name' => 'required|string|max:100',
'api_key' => 'nullable|string',
'api_endpoint' => 'required|url|max:255',
'model' => 'required|string|max:100',
'temperature' => 'nullable|numeric|min:0|max:2',
'max_tokens' => 'nullable|integer|min:100|max:32000',
'sort_order' => 'nullable|integer|min:0',
]);
// 只在用户提供了新 API Key 时才更新(空值表示不修改)
if (! empty($data['api_key'])) {
$data['api_key'] = Crypt::encryptString($data['api_key']);
} else {
unset($data['api_key']);
}
$provider->update($data);
return redirect()->route('admin.ai-providers.index')
->with('success', "已更新 {$provider->name} 的配置!");
}
/**
* 切换 AI 厂商的启用/禁用状态
*
* @param int $id 厂商配置 ID
* @return JsonResponse 操作结果
*/
public function toggleEnabled(int $id): JsonResponse
{
$provider = AiProviderConfig::findOrFail($id);
$provider->is_enabled = ! $provider->is_enabled;
$provider->save();
$status = $provider->is_enabled ? '启用' : '禁用';
return response()->json([
'status' => 'success',
'message' => "{$provider->name}{$status}",
'is_enabled' => $provider->is_enabled,
]);
}
/**
* 设置指定厂商为默认使用
*
* 互斥操作:将其他厂商的 is_default 全部置为 false。
*
* @param int $id 厂商配置 ID
* @return JsonResponse 操作结果
*/
public function setDefault(int $id): JsonResponse
{
$provider = AiProviderConfig::findOrFail($id);
// 先将所有厂商的默认标记清除
AiProviderConfig::where('is_default', true)->update(['is_default' => false]);
// 设置当前厂商为默认
$provider->is_default = true;
$provider->is_enabled = true; // 默认的必须是启用状态
$provider->save();
return response()->json([
'status' => 'success',
'message' => "{$provider->name} 已设为默认 AI 厂商",
]);
}
/**
* 切换聊天机器人全局开关
*
* 通过 sysparam 的 chatbot_enabled 参数控制是否在聊天室中显示 AI 机器人。
*
* @param Request $request 请求对象
* @return JsonResponse 操作结果
*/
public function toggleChatBot(Request $request): JsonResponse
{
$current = Sysparam::getValue('chatbot_enabled', '0');
$newValue = $current === '1' ? '0' : '1';
// 更新 sysparam
Sysparam::updateOrCreate(
['alias' => 'chatbot_enabled'],
[
'body' => $newValue,
'guidetxt' => 'AI聊天机器人开关1=开启0=关闭)',
]
);
// 刷新缓存
$this->chatState->setSysParam('chatbot_enabled', $newValue);
Sysparam::clearCache('chatbot_enabled');
$status = $newValue === '1' ? '开启' : '关闭';
$isEnabled = $newValue === '1';
// 确保 AI 实体账号存在
$user = \App\Models\User::firstOrCreate(
['username' => 'AI小班长'],
[
'password' => \Illuminate\Support\Facades\Hash::make(\Illuminate\Support\Str::random(16)),
'user_level' => 10,
'sex' => 0, // 女性
'usersf' => 'storage/avatars/ai_bot_cn_girl.png',
'jjb' => 1000000,
'sign' => '本群首席智慧小管家',
]
);
// 防止后期头像变动,强制更新到最新女生头像
if (! str_contains($user->usersf ?? '', 'ai_bot_cn_girl.png')) {
$user->update([
'usersf' => 'storage/avatars/ai_bot_cn_girl.png',
'sex' => 0,
]);
}
$userData = [
'user_id' => $user->id,
'username' => $user->username,
'level' => $user->user_level,
'sex' => $user->sex,
'headface' => $user->headface,
'vip_icon' => $user->vipIcon(),
'vip_name' => $user->vipName(),
'vip_color' => $user->isVip() ? ($user->vipLevel?->color ?? '') : '',
'is_admin' => false,
'position_icon' => '',
'position_name' => '',
];
// 广播机器人进出事件(供前端名单增删)
broadcast(new \App\Events\ChatBotToggled($userData, $isEnabled));
// 像真实的玩家一样,对全网活跃房间进行高调进出场播报
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
if (empty($activeRoomIds)) {
$activeRoomIds = [1]; // 兜底
}
// 把 AI 实体挂名到一个主房间,即可被 app/Console/Commands/AutoSaveExp.php 扫描发经验
$mainRoomId = $activeRoomIds[0];
if ($isEnabled) {
$this->chatState->userJoin($mainRoomId, $user->username, $userData);
} else {
// 清理可能存在的所有房间的残留挂名
foreach ($activeRoomIds as $rId) {
$this->chatState->userLeave($rId, $user->username);
}
}
foreach ($activeRoomIds as $roomId) {
$content = $isEnabled
? '<span style="color: #9333ea; font-weight: bold;">🤖 【AI小班长】 迈着整齐的步伐进入了房间,随时为您服务!</span>'
: '<span style="color: #9ca3af; font-weight: bold;">🤖 【AI小班长】 去休息啦,大家聊得开心!</span>';
$botMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '进出播报',
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => '#9333ea',
'action' => 'system_welcome',
'welcome_user' => $user->username,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $botMsg);
broadcast(new \App\Events\MessageSent($roomId, $botMsg));
\App\Jobs\SaveMessageJob::dispatch($botMsg);
}
return response()->json([
'status' => 'success',
'message' => "聊天机器人已{$status}",
'enabled' => $isEnabled,
]);
}
/**
* 测试指定 AI 厂商的接口连通性
*
* 通过 GET /v1/models 检查端点可达性与 API Key 有效性,毫秒级响应,
* 不触发模型推理,避免经 Cloudflare 代理时因推理耗时导致 524 超时。
*
* @param int $id 厂商配置 ID
* @return JsonResponse 测试结果(含可用模型列表)
*/
public function testConnection(int $id): JsonResponse
{
$provider = AiProviderConfig::findOrFail($id);
$apiKey = $provider->getDecryptedApiKey();
$base = rtrim($provider->api_endpoint, '/');
// 拼接 /v1/models 端点(检查连通性,不触发推理)
$modelsUrl = str_ends_with($base, '/v1')
? $base.'/models'
: $base.'/v1/models';
$startTime = microtime(true);
try {
$response = \Illuminate\Support\Facades\Http::withToken($apiKey)
->timeout(10)
->get($modelsUrl);
$ms = (int) ((microtime(true) - $startTime) * 1000);
if (! $response->successful()) {
return response()->json([
'ok' => false,
'message' => "HTTP {$response->status()}{$response->body()}",
'ms' => $ms,
]);
}
$data = $response->json();
// 提取可用模型列表(兼容 Ollama 和 OpenAI 格式)
$models = collect($data['models'] ?? $data['data'] ?? [])
->pluck('id')
->filter()
->values()
->toArray();
$modelList = count($models) > 0
? implode('、', array_slice($models, 0, 5)).(count($models) > 5 ? ' 等' : '')
: $provider->model;
return response()->json([
'ok' => true,
'message' => "接口连通正常,可用模型:{$modelList}",
'ms' => $ms,
'models' => $models,
]);
} catch (\Illuminate\Http\Client\ConnectionException $e) {
$ms = (int) ((microtime(true) - $startTime) * 1000);
return response()->json([
'ok' => false,
'message' => '连接失败:'.$e->getMessage(),
'ms' => $ms,
]);
}
}
/**
* 删除 AI 厂商配置
*
* @param int $id 厂商配置 ID
* @return RedirectResponse 重定向到列表页
*/
public function destroy(int $id): RedirectResponse
{
$provider = AiProviderConfig::findOrFail($id);
$name = $provider->name;
$provider->delete();
return redirect()->route('admin.ai-providers.index')
->with('success', "已删除 {$name}");
}
}