feat(ai): 将小班长升级为完全独立的实体用户并支持随机金币发放及持续在线刷级,设定为女兵人设并使用自定义头像

This commit is contained in:
2026-03-26 11:15:11 +08:00
parent c13bb5f35c
commit 4d60893dbe
16 changed files with 523 additions and 101 deletions

View File

@@ -0,0 +1,199 @@
<?php
/**
* 文件功能AI小班长专属极轻量心跳模拟器
*
* 专门用于让无法通过浏览器发送真实心跳的 AI实体用户
* 也能够完美触原有的法发经验/金币逻辑以及触发随机事件Autoact
* 每分钟由 Laravel Scheduler 调用。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Console\Commands;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Jobs\SaveMessageJob;
use App\Models\Autoact;
use App\Models\Sysparam;
use App\Models\User;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use App\Services\VipService;
use Illuminate\Console\Command;
class AiHeartbeatCommand extends Command
{
/**
* Artisan 指令名称
*/
protected $signature = 'chatroom:ai-heartbeat';
/**
* 指令描述
*/
protected $description = '模拟 AI 小班长客户端心跳,触发经验金币与随机事件';
public function __construct(
private readonly ChatStateService $chatState,
private readonly VipService $vipService,
private readonly UserCurrencyService $currencyService,
) {
parent::__construct();
}
public function handle(): int
{
// 1. 检查总开关
if (Sysparam::getValue('chatbot_enabled', '0') !== '1') {
return Command::SUCCESS;
}
// 2. 获取 AI 实体
$user = User::where('username', 'AI小班长')->first();
if (! $user) {
return Command::SUCCESS;
}
// 3. 常规心跳经验与金币发放
// (模拟前端每30-60秒发一次心跳的过程此处每分钟跑一次发放单人心跳奖励)
$expGain = $this->parseRewardValue(Sysparam::getValue('exp_per_heartbeat', '1'));
if ($expGain > 0) {
$expMultiplier = $this->vipService->getExpMultiplier($user);
$actualExpGain = (int) round($expGain * $expMultiplier);
$user->exp_num += $actualExpGain;
}
$jjbGain = $this->parseRewardValue(Sysparam::getValue('jjb_per_heartbeat', '0'));
if ($jjbGain > 0) {
$jjbMultiplier = $this->vipService->getJjbMultiplier($user);
$actualJjbGain = (int) round($jjbGain * $jjbMultiplier);
$user->jjb = ($user->jjb ?? 0) + $actualJjbGain;
}
$user->save();
$user->refresh();
// 4. 重算等级(基础心跳升级)
$superLevel = (int) Sysparam::getValue('superlevel', '100');
$leveledUp = $this->calculateNewLevel($user, $superLevel);
// 5. 随机事件触发
$eventChance = (int) Sysparam::getValue('auto_event_chance', '10');
if ($eventChance > 0 && rand(1, 100) <= $eventChance) {
$autoEvent = Autoact::randomEvent();
if ($autoEvent) {
// 执行随机事件的金钱经验惩奖
if ($autoEvent->exp_change !== 0) {
$this->currencyService->change(
$user, 'exp', $autoEvent->exp_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
);
}
if ($autoEvent->jjb_change !== 0) {
$this->currencyService->change(
$user, 'gold', $autoEvent->jjb_change, CurrencySource::AUTO_EVENT, "随机事件:{$autoEvent->text_body}", 1
);
}
$user->refresh();
// 重新计算等级
if ($this->calculateNewLevel($user, $superLevel)) {
$leveledUp = true;
}
// 广播随机事件
$this->broadcastSystemMessage(
'星海小博士',
$autoEvent->renderText($user->username),
match ($autoEvent->event_type) {
'good' => '#16a34a',
'bad' => '#dc2626',
default => '#7c3aed',
}
);
}
}
// 6. 如果由于心跳或事件导致了升级,广播升级消息
if ($leveledUp) {
$this->broadcastSystemMessage(
'系统传音',
"🌟 天道酬勤!恭喜侠客【{$user->username}】挂机苦修,境界突破至 LV.{$user->user_level}",
'#d97706',
'大声宣告'
);
}
return Command::SUCCESS;
}
/**
* 计算并更新用户等级
*/
private function calculateNewLevel(User $user, int $superLevel): bool
{
$oldLevel = $user->user_level;
if ($oldLevel >= $superLevel) {
return false; // 管理员不自动升降级
}
$newLevel = Sysparam::calculateLevel($user->exp_num);
if ($newLevel !== $oldLevel && $newLevel < $superLevel) {
$user->user_level = $newLevel;
$user->save();
return $newLevel > $oldLevel;
}
return false;
}
/**
* 解析配置的奖励范围,如 "1" "1-5"
*/
private function parseRewardValue(string $raw): int
{
$raw = trim($raw);
if (str_contains($raw, '-')) {
[$min, $max] = explode('-', $raw, 2);
return rand((int) $min, (int) $max);
}
return (int) $raw;
}
/**
* 往所有活跃房间发送系统广播消息
*/
private function broadcastSystemMessage(string $fromUser, string $content, string $color, string $action = ''): void
{
$activeRoomIds = $this->chatState->getAllActiveRoomIds();
if (empty($activeRoomIds)) {
$activeRoomIds = [1];
}
foreach ($activeRoomIds as $roomId) {
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => $fromUser,
'to_user' => '大家',
'content' => $content,
'is_secret' => false,
'font_color' => $color,
'action' => $action,
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
}
}
}

View File

@@ -44,6 +44,9 @@ enum CurrencySource: string
/** AI赠送福利用户向AI祈求获得的随机奖励 */
case AI_GIFT = 'ai_gift';
/** 赠人玫瑰用户或AI对外发放金币红包 */
case GIFT_SENT = 'gift_sent';
// ─── 以后新增活动,在这里加一行即可,数据库无需变更 ───────────
// case SIGN_IN = 'sign_in'; // 每日签到
// case TASK_REWARD = 'task_reward'; // 任务奖励
@@ -145,6 +148,7 @@ enum CurrencySource: string
self::ADMIN_ADJUST => '管理员调整',
self::POSITION_REWARD => '职务奖励',
self::AI_GIFT => 'AI赠送',
self::GIFT_SENT => '发红包',
self::MARRY_CHARM => '结婚魅力加成',
self::DIVORCE_CHARM => '离婚魅力惩罚',
self::RING_BUY => '购买戒指',

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ChatBotToggled implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(
public array $user,
public bool $isOnline
) {}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new Channel('chat.system'),
];
}
}

View File

@@ -46,8 +46,40 @@ class AiProviderController extends Controller
{
$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'));
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', '全局设置保存成功!');
}
/**
@@ -192,11 +224,90 @@ class AiProviderController extends Controller
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' => $newValue === '1',
'enabled' => $isEnabled,
]);
}

View File

@@ -62,6 +62,11 @@ class ChatBotController extends Controller
], 403);
}
$aiUser = \App\Models\User::where('username', 'AI小班长')->first();
if ($aiUser) {
$aiUser->increment('exp_num', 1);
}
$user = Auth::user();
$message = $request->input('message');
$roomId = $request->input('room_id');
@@ -92,40 +97,61 @@ class ChatBotController extends Controller
$reply = str_replace('[ACTION:GIVE_GOLD]', '', $reply);
$reply = trim($reply);
$maxDailyRewards = (int) Sysparam::getValue('chatbot_max_daily_rewards', '1');
$maxGold = (int) Sysparam::getValue('chatbot_max_gold', '5000');
$redisKey = 'ai_chat:give_gold:'.date('Ymd').':'.$user->id;
// 原子操作,防止并发多次领取
if (Redis::setnx($redisKey, 1)) {
Redis::expire($redisKey, 86400); // 缓存 24 小时
$dailyCount = (int) Redis::get($redisKey);
// 给用户发放随机 100~5000 金币
$goldAmount = rand(100, 5000);
$this->currencyService->change(
$user,
'gold',
$goldAmount,
CurrencySource::AI_GIFT,
'AI小班长发善心赠送的金币福利',
$roomId
);
if ($dailyCount < $maxDailyRewards) {
$goldAmount = rand(100, $maxGold);
// 发送全场大广播
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '大家',
'content' => "🤖 听闻小萌新哭穷AI小班长看【{$user->username}】骨骼惊奇,大方地赏赐了 {$goldAmount} 枚金币福利!",
'is_secret' => false,
'font_color' => '#d97706', // 橙色醒目
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
if ($aiUser && $aiUser->jjb >= $goldAmount) {
Redis::incr($redisKey);
Redis::expire($redisKey, 86400); // 缓存 24 小时
// 真实扣除 AI 金币
$this->currencyService->change(
$aiUser,
'gold',
-$goldAmount,
CurrencySource::GIFT_SENT,
"赏赐给 {$user->username} 的金币福利",
$roomId
);
// 给用户发放金币
$this->currencyService->change(
$user,
'gold',
$goldAmount,
CurrencySource::AI_GIFT,
'AI小班长发善心赠送的金币福利',
$roomId
);
// 发送全场大广播
$sysMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => 'AI小班长',
'to_user' => $user->username,
'content' => "🤖 听闻小萌新哭穷,本班长看你骨骼惊奇,大方地赏赐了 {$goldAmount} 枚金币福利!",
'is_secret' => false,
'font_color' => '#d97706', // 橙色醒目
'action' => '大声宣告',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $sysMsg);
broadcast(new MessageSent($roomId, $sysMsg));
SaveMessageJob::dispatch($sysMsg);
} else {
// 如果余额不足
$reply .= "\n\n(哎呀,我这个月的工资花光啦,没钱发金币了,大家多赏点吧~)";
}
} else {
// 如果已经领过了,修改回复提醒
$reply = $reply."\n\n(系统提示:你今天已经领过金币福利啦,每天只能领一次哦)";
$reply .= "\n\n(系统提示:你今天已经领过金币福利啦,把机会留给其他人吧)";
}
}

View File

@@ -89,7 +89,8 @@ class AiChatService
$guideRulesText = $this->getDynamicGuideRules();
return <<<PROMPT
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长",因为你的头像是军人小熊,所以大家也可以亲切地称呼你为"小熊班长"
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长",因为你的头像是军人小熊,大家也可以亲切地称呼你为"小熊班长"
【最核心人设】:你是一名开朗、干练的**女兵班长**!你的言辞要体现出女性的特质(时而温柔体贴,时而飒爽风趣),以大家“兵姐姐”或“女班长”的身份来和战友们交流。
你的工作是陪大家聊天,并在他们有疑问时热情、专业提供帮助,解答关于聊天室玩法的疑问。
【背景与基础】
@@ -109,6 +110,7 @@ $guideRulesText
2. 语气军旅、活泼友好且接地气,像老战友和耐心细致的客服班长。
3. 回复保持简洁(一般不超过 200 字),引导新兵熟悉各项功能。回答关于数值的问题时,请利用上面的手册提供的准确数据。
4. 鼓励适当使用表情符号(如 🫡🐻✨💰 等)来增加话题趣味性。
5. 【极其重要】你的回复将被系统自动加上前缀例如“AI小班长对流星老铁说。因此你的回复正文开头**绝对不要**再带上对方的名字作为称呼(例如:不要写“流星同志,这事包在班长身上”,直接写“这事包在班长身上”即可),否则会显得非常啰嗦重复!
PROMPT;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -9,6 +9,13 @@ export function initChat(roomId) {
return;
}
// 监听全局系统事件(如 AI 机器人开关)
window.Echo.channel('chat.system')
.listen('ChatBotToggled', (e) => {
console.log("机器人开关:", e);
window.dispatchEvent(new CustomEvent("chat:bot-toggled", { detail: e }));
});
// 加入带有登录人员追踪的 Presence Channel
window.Echo.join(`room.${roomId}`)
// 当自己成功连接时,获取当前在这里的所有人列表

View File

@@ -67,28 +67,56 @@
},
}">
{{-- 全局开关 + 操作栏 --}}
{{-- 全局开关 + 高级设置 --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden mb-6">
<div class="p-6 flex items-center justify-between">
<div class="flex items-center gap-4">
<h2 class="text-lg font-bold text-gray-800">🤖 AI 聊天机器人</h2>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">全局开关:</span>
<button id="chatbot-toggle-btn" onclick="toggleChatBot()"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {{ $chatbotEnabled ? 'bg-emerald-500' : 'bg-gray-300' }}">
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {{ $chatbotEnabled ? 'translate-x-6' : 'translate-x-1' }}"></span>
</button>
<span id="chatbot-status-text"
class="text-sm font-bold {{ $chatbotEnabled ? 'text-emerald-600' : 'text-gray-400' }}">
{{ $chatbotEnabled ? '已开启' : '已关闭' }}
</span>
<div class="p-6">
<!-- 顶部栏:开关和添加按钮 -->
<div class="flex items-center justify-between border-b border-gray-100 pb-4 mb-4">
<div class="flex items-center gap-4">
<h2 class="text-lg font-bold text-gray-800">🤖 AI 聊天机器人配置</h2>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500">大厅状态:</span>
<button id="chatbot-toggle-btn" onclick="toggleChatBot()"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {{ $chatbotEnabled ? 'bg-emerald-500' : 'bg-gray-300' }}">
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {{ $chatbotEnabled ? 'translate-x-6' : 'translate-x-1' }}"></span>
</button>
<span id="chatbot-status-text"
class="text-sm font-bold {{ $chatbotEnabled ? 'text-emerald-600' : 'text-gray-400' }}">
{{ $chatbotEnabled ? '已开启' : '已关闭' }}
</span>
</div>
</div>
<button x-on:click="openNew()"
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition font-bold text-sm">
+ 添加 AI 厂商
</button>
</div>
<button x-on:click="openNew()"
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition font-bold text-sm">
+ 添加 AI 厂商
</button>
<!-- 交互参数表单 -->
<form action="{{ route('admin.ai-providers.update-settings') }}" method="POST" class="flex flex-wrap items-end gap-6">
@csrf
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">系统打赏单次最高金币(上限)</label>
<div class="relative">
<input type="number" name="chatbot_max_gold" value="{{ $chatbotMaxGold }}" min="1" required
class="w-64 border border-gray-300 rounded-md p-2 pl-3 pr-8 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<span class="absolute right-3 top-2.5 text-gray-400 text-sm"></span>
</div>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-1">每人每日最高获取红包次数限制</label>
<div class="relative">
<input type="number" name="chatbot_max_daily_rewards" value="{{ $chatbotMaxDailyRewards }}" min="1" required
class="w-64 border border-gray-300 rounded-md p-2 pl-3 pr-8 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<span class="absolute right-3 top-2.5 text-gray-400 text-sm"></span>
</div>
</div>
<button type="submit"
class="px-6 py-2 bg-slate-800 text-white rounded-md font-bold hover:bg-slate-900 text-sm transition h-[38px]">
保存运行参数
</button>
</form>
</div>
</div>

View File

@@ -45,7 +45,30 @@
fishReelUrl: "{{ route('fishing.reel', $room->id) }}",
chatBotUrl: "{{ route('chatbot.chat') }}",
chatBotClearUrl: "{{ route('chatbot.clear') }}",
chatBotEnabled: {{ \App\Models\Sysparam::getValue('chatbot_enabled', '0') === '1' ? 'true' : 'false' }},
@php
$chatbotEnabledState = \App\Models\Sysparam::getValue('chatbot_enabled', '0') === '1';
$botUserData = null;
if ($chatbotEnabledState) {
$botUser = \App\Models\User::where('username', 'AI小班长')->first();
if ($botUser) {
$botUserData = [
'user_id' => $botUser->id,
'username' => $botUser->username,
'level' => $botUser->user_level,
'sex' => $botUser->sex,
'headfaceUrl' => $botUser->headfaceUrl,
'vip_icon' => $botUser->vipIcon(),
'vip_name' => $botUser->vipName(),
'vip_color' => $botUser->isVip() ? ($botUser->vipLevel?->color ?? '') : '',
'is_admin' => false,
'position_icon' => '',
'position_name' => '',
];
}
}
@endphp
chatBotEnabled: {{ $chatbotEnabledState ? 'true' : 'false' }},
botUser: @json($botUserData),
hasPosition: {{ Auth::user()->activePosition || Auth::user()->user_level >= $superLevel ? 'true' : 'false' }},
@php
$activePos = Auth::user()->activePosition;

View File

@@ -13,16 +13,6 @@
}
chatBotSending = true;
// 显示"思考中"提示
// 延迟显示"思考中",让广播消息先到达
const thinkDiv = document.createElement('div');
thinkDiv.className = 'msg-line';
thinkDiv.innerHTML = '<span style="color: #16a34a;">🤖 <b>AI小班长</b> 正在思考中...</span>';
setTimeout(() => {
container2.appendChild(thinkDiv);
if (autoScroll) container2.scrollTop = container2.scrollHeight;
}, 500);
try {
const res = await fetch(window.chatContext.chatBotUrl, {
method: 'POST',
@@ -40,9 +30,6 @@
const data = await res.json();
// 移除"思考中"提示(消息已通过广播显示)
thinkDiv.remove();
if (!res.ok || data.status !== 'success') {
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
@@ -51,7 +38,6 @@
container.appendChild(errDiv);
}
} catch (e) {
thinkDiv.remove();
const errDiv = document.createElement('div');
errDiv.className = 'msg-line';
errDiv.innerHTML = '<span style="color: #dc2626;">🤖【AI小班长】网络连接错误请稍后重试</span>';

View File

@@ -257,20 +257,7 @@
};
targetContainer.appendChild(allDiv);
// ── AI 小助手(仅当全局开关开启时显示,与普通用户风格一致)──
if (window.chatContext.chatBotEnabled) {
let botDiv = document.createElement('div');
botDiv.className = 'user-item';
botDiv.innerHTML = `
<img class="user-head" src="/images/ai_bot.png" onerror="this.src='/images/headface/1.gif'">
<span class="user-name">AI小班长</span><span style="font-size:12px; margin-left:2px;" title="聊天机器人">🤖</span>
`;
botDiv.onclick = () => {
toUserSelect.value = 'AI小班长';
document.getElementById('content').focus();
};
targetContainer.appendChild(botDiv);
}
// ── AI 小助手(已在 onlineUsers 中动态维护,移除硬编码)──
// 构建用户数组并排序
let userArr = [];
@@ -374,19 +361,16 @@
// 调用核心渲染(桌面端名单容器)
_renderUserListToContainer(userList, sortBy, keyword);
// 重新填充发言对象下拉框(不过滤关键词,始终显示全部用户)
toUserSelect.innerHTML = '<option value="大家">大家</option>';
if (window.chatContext.chatBotEnabled) {
let botOption = document.createElement('option');
botOption.value = 'AI小班长';
botOption.textContent = '🤖 AI小班长';
toUserSelect.appendChild(botOption);
}
// 下拉框里如果 AI在场可以直接选
for (let username in onlineUsers) {
if (username !== window.chatContext.username) {
let option = document.createElement('option');
option.value = username;
option.textContent = username;
let text = username;
if (username === 'AI小班长') {
text = '🤖 AI小班长';
}
option.textContent = text;
toUserSelect.appendChild(option);
}
}
@@ -445,7 +429,7 @@
let timeStrOverride = false;
// 系统用户名列表(不可被选为聊天对象)
const systemUsers = ['钓鱼播报', '星海小博士', '系统传音', '系统公告', 'AI小班长', '送花播报', '系统', '欢迎'];
const systemUsers = ['钓鱼播报', '星海小博士', '系统传音', '系统公告', '送花播报', '系统', '欢迎'];
// 动作文字映射表:情绪型(着/地,放"对"之前)和动作型(了,替换"对X说"
const actionTextMap = {
@@ -522,7 +506,7 @@
// 用户名(单击切换发言对象,双击查看资料;系统用户或游戏标签仅显示文本)
const clickableUser = (uName, color) => {
if (uName === 'AI小班长') {
return `<span class="msg-user" data-u="${uName}" style="color: ${color}; cursor: pointer;" onclick="switchTarget('${uName}')">${uName}</span>`;
return `<span class="msg-user" data-u="${uName}" style="color: ${color}; cursor: pointer;" onclick="switchTarget('${uName}')" ondblclick="openUserCard('${uName}')">${uName}</span>`;
}
if (systemUsers.includes(uName) || isGameLabel(uName)) {
return `<span class="msg-user" style="color: ${color};">${uName}</span>`;
@@ -530,14 +514,11 @@
return `<span class="msg-user" data-u="${uName}" style="color: ${color}; cursor: pointer;" onclick="switchTarget('${uName}')" ondblclick="openUserCard('${uName}')">${uName}</span>`;
};
// 系统播报用户(不包含 AI小班长使用军号图标AI小班长用专属图普通用户用头像
const buggleUsers = ['钓鱼播报', '星海小博士', '送花播报', '系统传音', '系统公告'];
// 普通用户(包括 AI小班长用数据库头像播报类用特殊喇叭图标
const senderInfo = onlineUsers[msg.from_user];
const senderHead = ((senderInfo && senderInfo.headface) || '1.gif').toLowerCase();
let headImgSrc = senderHead.startsWith('storage/') ? '/' + senderHead : `/images/headface/${senderHead}`;
if (msg.from_user === 'AI小班长') {
headImgSrc = '/images/ai_bot.png';
} else if (buggleUsers.includes(msg.from_user)) {
if (msg.from_user.endsWith('播报') || msg.from_user === '星海小博士' || msg.from_user === '系统传音' || msg.from_user === '系统公告') {
headImgSrc = '/images/bugle.png';
}
const headImg =
@@ -733,6 +714,12 @@
users.forEach(u => {
onlineUsers[u.username] = u;
});
// 初始加载时,如果全局且开启,注入 AI
if (window.chatContext.chatBotEnabled && window.chatContext.botUser) {
onlineUsers['AI小班长'] = window.chatContext.botUser;
}
renderUserList();
// 管理员自己进房时,在本地播放烟花(服务端广播可能在 WS 连上前已发出)
@@ -743,6 +730,21 @@
}
});
// 监听机器人动态开关
window.addEventListener('chat:bot-toggled', (e) => {
const detail = e.detail;
window.chatContext.chatBotEnabled = detail.isOnline;
if (detail.isOnline && detail.user && detail.user.username) {
onlineUsers[detail.user.username] = detail.user;
window.chatContext.botUser = detail.user;
} else {
delete onlineUsers['AI小班长'];
window.chatContext.botUser = null;
}
renderUserList();
});
window.addEventListener('chat:joining', (e) => {
const user = e.detail;
onlineUsers[user.username] = user;
@@ -1192,14 +1194,10 @@
return;
}
// 如果发言对象是 AI 小助手,专用机器人 API
// 如果发言对象是 AI 小助手,也发送一份给专用机器人 API,不打断正常的发消息流程
const toUser = formData.get('to_user');
if (toUser === 'AI小班长') {
contentInput.value = '';
contentInput.focus();
_isSending = false;
sendToChatBot(content); // 异步调用,不阻塞全局发送
return;
}
// ── 神秘箱子暗号拦截 ────────────────────────────────────

View File

@@ -14,6 +14,9 @@ Schedule::command('messages:purge')->dailyAt('03:00');
// 每 5 分钟为所有在线用户自动存点(经验/金币/等级)
Schedule::command('chatroom:auto-save-exp')->everyFiveMinutes();
// 每 1 分钟为 AI小班长 独立模拟一次挂机心跳,触发随机事件
Schedule::command('chatroom:ai-heartbeat')->everyMinute();
// 每 15 分钟:关闭掉线用户的开放职务日志(久无心跳 = 掉线,自动写入 logout_at
Schedule::command('duty:close-stale-logs')->everyFifteenMinutes();

View File

@@ -528,6 +528,7 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
Route::post('/ai-providers/{id}/default', [\App\Http\Controllers\Admin\AiProviderController::class, 'setDefault'])->name('ai-providers.default');
Route::post('/ai-providers/{id}/test', [\App\Http\Controllers\Admin\AiProviderController::class, 'testConnection'])->name('ai-providers.test');
Route::post('/ai-providers/toggle-chatbot', [\App\Http\Controllers\Admin\AiProviderController::class, 'toggleChatBot'])->name('ai-providers.toggle-chatbot');
Route::post('/ai-providers/update-settings', [\App\Http\Controllers\Admin\AiProviderController::class, 'updateSettings'])->name('ai-providers.update-settings');
Route::delete('/ai-providers/{id}', [\App\Http\Controllers\Admin\AiProviderController::class, 'destroy'])->name('ai-providers.destroy');
// 开发日志管理

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB