码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
index.js97.3 KB
/**
 * 内置插件:智能助手
 * --------------------------------------------------
 * 无需特定指令前缀,所有消息交给 AI 通过 function calling 自动分析意图。
 * AI 会自行判断用户需要搜索知识库、设置提醒、获取新闻还是闲聊。
 *
 * 支持的意图(通过 function calling):
 *   - 搜索 IMA 知识库(自然语言查询)
 *   - 设置/查看/删除定时提醒
 *   - 获取每日新闻简报
 *   - 获取随机图片
 *   - 日常对话
 *
 * AI 配置使用管理员在后台统一设置的 OpenAI 兼容 API。
 * IMA 知识库配置使用每机器人的 plugin_settings。
 */

const axios = require('axios');
const fs = require('fs');
const path = require('path');
const db = require('../../lib/db');
const msgEvents = require('../../lib/msg-events');
const settings = require('../../lib/settings');
const silk = require('silk-wasm');
const stt = require('../../lib/stt');


// ==================== 常量 ====================
const IMA_BASE = 'https://ima.qq.com';
const IMA_API = IMA_BASE + '/openapi/wiki/v1';
const NOTE_API = IMA_BASE + '/openapi/note/v1';
const TMP_DIR = path.join(__dirname, '..', '..', 'data', 'tmp');
const CACHE_TTL = 30 * 60 * 1000; // 简报缓存 30 分钟
const CONV_TTL = 30 * 60 * 1000;  // 对话历史 30 分钟
const MAX_TOOL_LOOPS = 5;

// ==================== 提醒调度器(处理智能助手创建的提醒) ====================
// 与 reminder 插件共用 reminders 表:用 global 单例标志保证整个进程只启动一个
// 30s 定时器(谁先被调用谁启动),避免 smart / reminder 双调度器导致重复提醒。
/** reminders 表只建一次,避免每条消息都执行 DDL */
let _remindersTableReady = false;
async function ensureRemindersTable() {
  if (_remindersTableReady) return;
  await db.exec(`CREATE TABLE IF NOT EXISTS reminders (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    bot_id INTEGER NOT NULL,
    peer_id VARCHAR(128) NOT NULL,
    context_token VARCHAR(256),
    content TEXT NOT NULL,
    remind_at INTEGER NOT NULL,
    repeat_type VARCHAR(16) NOT NULL DEFAULT 'once',
    repeat_rule VARCHAR(64) DEFAULT '',
    created_at INTEGER NOT NULL,
    fired INTEGER NOT NULL DEFAULT 0
  )`);
  _remindersTableReady = true;
}

function startReminderScheduler() {
  if (global.__reminderSchedulerStarted) return;
  global.__reminderSchedulerStarted = true;
  const ILink = require('../../lib/ilink');
  const cron = require('../../lib/cron');

  setInterval(async () => {
    try {
      const now = Math.floor(Date.now() / 1000);
      const due = await db.rows(
        "SELECT r.*, b.bot_token, b.base_url FROM reminders r JOIN bots b ON r.bot_id = b.id WHERE r.remind_at <= ? AND r.fired = 0 AND COALESCE(r.enabled,1) = 1",
        [now]
      );
      const pluginsLib = require('../../lib/plugins');
      for (const r of due) {
        try {
          const ctxToken = await resolveReminderCtx(r.bot_id, r.peer_id);
          if (!ctxToken) continue;

          // 联动其他插件:把提醒内容当作指令分发给 rss/每日简报 等插件,
          // 未命中任何插件时再退回「⏰ 提醒:内容」纯文本兜底(AI 兜底在 dispatchReminder 内完成)。
          const bot = await db.row('SELECT * FROM bots WHERE id=?', [r.bot_id]);
          let handled = false;
          if (bot) {
            handled = await pluginsLib.dispatchReminder(bot, r.content, r.peer_id, ctxToken);
          }

          if (!handled) {
            const il = new ILink({ bot_token: r.bot_token, base_url: r.base_url });
            const resp = await il.sendMessage(r.peer_id, ctxToken, '⏰ 提醒:' + r.content);
            if ((resp.ret ?? -1) !== 0) continue;

            const reminderText = '⏰ 提醒:' + r.content;
            await db.exec(
              'INSERT INTO messages (bot_id, direction, peer_id, content, msg_type, context_token, created_at) VALUES (?,?,?,?,?,?,?)',
              [r.bot_id, 'out', r.peer_id, reminderText, 'text', ctxToken, now]
            );
          }

          if (r.repeat_type === 'daily') {
            await db.exec('UPDATE reminders SET remind_at = remind_at + 86400, fired = 0 WHERE id = ?', [r.id]);
          } else if (r.repeat_type === 'weekly') {
            await db.exec('UPDATE reminders SET remind_at = remind_at + 604800, fired = 0 WHERE id = ?', [r.id]);
          } else if (r.repeat_type === 'interval') {
            const m = (r.repeat_rule || '').match(/^(min|hour):(\d+)$/);
            const step = m ? (m[1] === 'min' ? parseInt(m[2], 10) * 60 : parseInt(m[2], 10) * 3600) : 60;
            const next = now + step;
            await db.exec('UPDATE reminders SET remind_at = ?, fired = 0 WHERE id = ?', [next, r.id]);
          } else if (r.repeat_type === 'cron') {
            try {
              const nxt = cron.nextCronTime(r.repeat_rule, now);
              if (nxt) {
                await db.exec('UPDATE reminders SET remind_at = ?, fired = 0 WHERE id = ?', [nxt, r.id]);
              } else {
                await db.exec('UPDATE reminders SET fired = 1 WHERE id = ?', [r.id]);
              }
            } catch (e) {
              console.error('[smart-scheduler] cron 计算失败, 停止任务 id=' + r.id + ':', e.message);
              await db.exec('UPDATE reminders SET fired = 1 WHERE id = ?', [r.id]);
            }
          } else {
            // 一次性任务触发后直接删除,不再保留「已触发」记录
            await db.exec('DELETE FROM reminders WHERE id = ?', [r.id]);
          }
        } catch (err) {
          console.error('[smart-scheduler] 提醒 id=' + r.id + ' 发送失败:', err.message);
        }
      }
    } catch (err) {
      console.error('[smart-scheduler] 调度错误:', err.message);
    }
  }, 30000).unref();

  console.log('[smart] 提醒调度器已启动');
}

// ============ 主动陪伴调度器 ============
// 已开启「主动陪伴」的用户,在白天时段随机收到一条「朋友式问候」或随机一个插件的推送。
// 需用户主动开启(companion_enabled=1),默认关闭。
const COMPANION_GREETINGS = [
  '忙了一天啦,记得喝口水、伸个懒腰休息下哦 🌿',
  '刚才突然想起你啦~今天过得还顺利吗?',
  '天晴的话,要不要出去走走透透气?☀️',
  '夜深了,别熬太晚,早点休息呀 🌙',
  '小小提醒:你今天也超棒的,要开开心心的!',
  '想你了(bushi)… 有啥想聊的随时找我呀 💬',
  '喝水小卫士上线:该补水啦 💧',
  '发呆也是种本事,偶尔放空一下也不错~',
];
// 随机推送的插件指令(命中的插件需已安装且启用,否则退回问候)
const COMPANION_PLUGIN_CMDS = ['金价', '三角洲', '早报'];

function startCompanionScheduler() {
  if (global.__companionSchedulerStarted) return;
  global.__companionSchedulerStarted = true;
  const ILink = require('../../lib/ilink');
  const pluginsLib = require('../../lib/plugins');

  setInterval(async () => {
    try {
      const now = Math.floor(Date.now() / 1000);
      const hour = new Date().getHours();
      // 仅在 9:00 - 23:00 推送,不打扰睡眠
      if (hour < 9 || hour >= 23) return;

      const users = await db.rows(
        'SELECT id, companion_last_at FROM users WHERE companion_enabled = 1'
      );
      for (const u of users) {
        try {
          // 节流:同一用户至少间隔 4 小时才再推送一次
          if (u.companion_last_at && now - u.companion_last_at < 4 * 3600) continue;

          const bots = await db.rows('SELECT * FROM bots WHERE user_id = ?', [u.id]);
          if (!bots.length) continue;

          let pushed = false;
          for (const bot of bots) {
            const peer = await db.row(
              "SELECT peer_id FROM messages WHERE bot_id = ? AND direction = 'in' AND peer_id IS NOT NULL AND peer_id <> '' ORDER BY id DESC LIMIT 1",
              [bot.id]
            );
            if (!peer || !peer.peer_id) continue;
            const ctxToken = await resolveReminderCtx(bot.id, peer.peer_id);
            if (!ctxToken) continue;

            // 随机:50% 推送一个插件,否则发一条问候
            let sent = false;
            if (Math.random() < 0.5) {
              const cmd = COMPANION_PLUGIN_CMDS[Math.floor(Math.random() * COMPANION_PLUGIN_CMDS.length)];
              sent = await pluginsLib.dispatchReminder(bot, cmd, peer.peer_id, ctxToken);
            }
            if (!sent) {
              const greet = COMPANION_GREETINGS[Math.floor(Math.random() * COMPANION_GREETINGS.length)];
              const il = new ILink(bot);
              const resp = await il.sendMessage(peer.peer_id, ctxToken, greet);
              sent = (resp.ret ?? -1) === 0;
            }
            if (sent) {
              await db.exec('UPDATE users SET companion_last_at = ? WHERE id = ?', [now, u.id]);
              pushed = true;
              break; // 一个 bot 推送成功即可
            }
          }
          if (!pushed) {
            // 本次上下文失效等导致未推送:顺延 30 分钟再试,避免频繁重试
            await db.exec('UPDATE users SET companion_last_at = ? WHERE id = ?', [now - 4 * 3600 + 1800, u.id]);
          }
        } catch (err) {
          console.error('[companion] 用户 id=' + u.id + ' 推送失败:', err.message);
        }
      }
    } catch (err) {
      console.error('[companion] 调度错误:', err.message);
    }
  }, 10 * 60 * 1000).unref();

  console.log('[smart] 主动陪伴调度器已启动');
}
// 进程启动时即拉起调度器(与提醒调度器不同,不依赖首条消息触发)
startCompanionScheduler();

async function resolveReminderCtx(botId, peerId) {
  if (peerId) {
    const lastIn = await db.row(
      "SELECT context_token FROM messages WHERE bot_id=? AND peer_id=? AND direction='in' AND context_token IS NOT NULL AND context_token!='' ORDER BY id DESC LIMIT 1",
      [botId, peerId]
    );
    if (lastIn && lastIn.context_token) return lastIn.context_token;
  }
  const latestIn = await db.row(
    "SELECT context_token FROM messages WHERE bot_id=? AND direction='in' AND context_token IS NOT NULL AND context_token!='' ORDER BY id DESC LIMIT 1",
    [botId]
  );
  if (latestIn && latestIn.context_token) return latestIn.context_token;
  const bot = await db.row('SELECT context_token FROM bots WHERE id = ?', [botId]);
  if (bot && bot.context_token) return bot.context_token;
  const lastOut = await db.row(
    "SELECT context_token FROM messages WHERE bot_id=? AND direction='out' AND context_token IS NOT NULL AND context_token!='' ORDER BY id DESC LIMIT 1",
    [botId]
  );
  return lastOut ? lastOut.context_token : null;
}

// ==================== 对话历史缓存 ====================
const convCache = new Map(); // key: "botId:peerId" -> [{role,content},...]

function getConvKey(botId, peerId) {
  return botId + ':' + peerId;
}

function addConvMessage(botId, peerId, role, content) {
  const key = getConvKey(botId, peerId);
  let arr = convCache.get(key);
  if (!arr) {
    // 限制最多 500 个会话 key,超出则清理空数组或最旧的
    if (convCache.size >= 500) {
      let removed = false;
      for (const [k, v] of convCache) {
        if (!v || v.length === 0) { convCache.delete(k); removed = true; break; }
      }
      if (!removed) {
        // 删除最旧创建的 key
        const firstKey = convCache.keys().next().value;
        if (firstKey) convCache.delete(firstKey);
      }
    }
    arr = []; convCache.set(key, arr);
  }
  arr.push({ role, content, ts: Date.now() });
  // 只保留最近 20 条 + 清理超时的
  const now = Date.now();
  while (arr.length > 20) arr.shift();
  // 清理过期
  for (let i = arr.length - 1; i >= 0; i--) {
    if (now - arr[i].ts > CONV_TTL) arr.splice(i, 1);
  }
}

function getConvHistory(botId, peerId) {
  const key = getConvKey(botId, peerId);
  const arr = convCache.get(key);
  if (!arr) return [];
  const now = Date.now();
  // 清理过期
  for (let i = arr.length - 1; i >= 0; i--) {
    if (now - arr[i].ts > CONV_TTL) arr.splice(i, 1);
  }
  return arr.slice(-10).map(m => ({ role: m.role, content: m.content }));
}

// ==================== 提醒时间解析(复用 reminder 插件的逻辑) ====================
const WEEKDAY_MAP = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '日': 7, '天': 7 };

function adjustHour(hour, period) {
  if (period) {
    if (period.includes('早') || period.includes('上')) return hour;
    if (period.includes('中')) return hour === 12 ? 12 : hour + 12;
    if (period.includes('下') || period.includes('晚') || period.includes('傍')) {
      if (hour === 12) return 12;
      return hour + 12;
    }
  }
  return hour;
}

function parseTime(str) {
  str = str.trim();
  const now = new Date();
  let target = new Date(now);
  let repeatType = 'once';
  let repeatRule = '';

  // 绝对时间: 2026-07-20 14:30
  let m = str.match(/^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})$/);
  if (m) {
    target = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], 0, 0);
    return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
  }
  // 相对时间: X分钟后 / X小时后
  m = str.match(/^(\d+)\s*(分钟|分)\s*后$/);
  if (m) { target.setMinutes(target.getMinutes() + parseInt(m[1])); return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule }; }
  m = str.match(/^(\d+)\s*(小时|时|个?小时)\s*后$/);
  if (m) { target.setHours(target.getHours() + parseInt(m[1])); return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule }; }
  // 每天重复
  m = str.match(/^每天\s*(早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
  if (m) {
    let hour = adjustHour(parseInt(m[2]), m[1] || '');
    let min = m[4] ? parseInt(m[4]) : 0;
    target.setHours(hour, min, 0, 0);
    if (target <= now) target.setDate(target.getDate() + 1);
    return { target: Math.floor(target.getTime() / 1000), repeatType: 'daily', repeatRule: '' };
  }
  // 每周重复
  m = str.match(/^每周([一二三四五六日天])\s*(早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
  if (m) {
    let wday = WEEKDAY_MAP[m[1]];
    let hour = adjustHour(parseInt(m[3]), m[2] || '');
    let min = m[5] ? parseInt(m[5]) : 0;
    target.setHours(hour, min, 0, 0);
    let diff = wday - target.getDay();
    if (diff <= 0) diff += 7;
    target.setDate(target.getDate() + diff);
    if (target <= now) target.setDate(target.getDate() + 7);
    return { target: Math.floor(target.getTime() / 1000), repeatType: 'weekly', repeatRule: String(wday) };
  }
  // 明天 X点
  m = str.match(/^明天\s*(早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
  if (m) {
    let hour = adjustHour(parseInt(m[2]), m[1] || '');
    let min = m[4] ? parseInt(m[4]) : 0;
    target.setDate(target.getDate() + 1);
    target.setHours(hour, min, 0, 0);
    return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
  }
  // 今天/时段 X点
  m = str.match(/^(今天|早上|上午|中午|下午|晚上|傍晚)?\s*(\d{1,2})\s*(点|:)(\d{2})?\s*$/);
  if (m) {
    let hour = adjustHour(parseInt(m[2]), m[1] || '');
    let min = m[4] ? parseInt(m[4]) : 0;
    target.setHours(hour, min, 0, 0);
    if (target <= now) target.setDate(target.getDate() + 1);
    return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
  }
  // 仅数字时间 9:00 / 14:30
  m = str.match(/^(\d{1,2}):(\d{2})\s*$/);
  if (m) {
    target.setHours(parseInt(m[1]), parseInt(m[2]), 0, 0);
    if (target <= now) target.setDate(target.getDate() + 1);
    return { target: Math.floor(target.getTime() / 1000), repeatType, repeatRule };
  }
  return null;
}

// ==================== 简报缓存 ====================
let briefingCache = null;
const BRIEFING_API = 'https://v2.xxapi.cn/api/hot60s';

async function fetchBriefing() {
  if (briefingCache && Date.now() - briefingCache.time < CACHE_TTL) {
    return briefingCache.imageUrl;
  }
  try {
    const { data: resp } = await axios.get(BRIEFING_API, {
      timeout: 10000, headers: { 'User-Agent': 'Mozilla/5.0' },
    });
    if (resp.code === 200 && resp.data) {
      briefingCache = { imageUrl: resp.data, time: Date.now() };
      return resp.data;
    }
  } catch (e) { /* fall through */ }
  return null;
}

// ==================== 全局配置加载 ====================
async function loadSmartConfig() {
  const api_base = (await settings.getSetting('ai_api_base', 'https://api.openai.com')).replace(/\/+$/, '').replace(/\/v1\/?$/, '');
  const api_key = await settings.getSetting('ai_api_key', '');
  const model = await settings.getSetting('ai_model', 'gpt-4o-mini');
  // 多接口备选(后台「AI 接口」配置为列表,支持轮询 + 故障转移)
  let endpoints = [];
  try { endpoints = JSON.parse(await settings.getSetting('ai_endpoints', '[]')) || []; } catch (e) { endpoints = []; }
  endpoints = (Array.isArray(endpoints) ? endpoints : [])
    .filter(e => e && e.api_base && e.api_key)
    .map(e => ({
      name: e.name || e.model || '接口',
      api_base: normalizeBase(e.api_base),
      api_key: e.api_key,
      model: (e.model || model).trim(),
    }));
  return {
    api_base, api_key, model,
    endpoints,
    endpoint_strategy: await settings.getSetting('ai_endpoint_strategy', 'round_robin'),
    system_prompt: await settings.getSetting('smart_system_prompt', ''),
    max_tokens: parseInt(await settings.getSetting('ai_max_tokens', '2000'), 10) || 2000,
    temperature: parseFloat(await settings.getSetting('ai_temperature', '0.7')) || 0.7,
    // 语音识别模型(OpenAI 兼容 /v1/audio/transcriptions),默认 whisper-1
    transcribe_model: await settings.getSetting('ai_transcribe_model', 'whisper-1'),
    // 语音识别模式:local=本地 Whisper(纯本地无外部接口,默认);ai=OpenAI 兼容接口;mimo=米米MIMO语音识别
    stt_mode: await settings.getSetting('ai_stt_mode', 'local'),
    // 本地 Whisper 模型(HuggingFace 仓库 ID),默认 multilingual base
    stt_model: await settings.getSetting('ai_stt_model', 'Xenova/whisper-base'),
    // MIMO 语音识别(api.xiaomimimo.com)配置
    stt_mimo_api_key: await settings.getSetting('ai_stt_mimo_api_key', ''),
    stt_mimo_base: (await settings.getSetting('ai_stt_mimo_base', 'https://api.xiaomimimo.com')).replace(/\/+$/, ''),
    stt_mimo_model: await settings.getSetting('ai_stt_mimo_model', 'mimo-v2.5-asr'),
    stt_mimo_lang: await settings.getSetting('ai_stt_mimo_lang', 'zh'),
    // 图片生成(独立 API 配置,不引用 AI 对话接口)
    image_gen_enabled: await settings.getSetting('ai_image_gen_enabled', '1'),
    image_gen_api_base: (await settings.getSetting('ai_image_gen_api_base', '')).replace(/\/+$/, ''),
    image_gen_api_key: await settings.getSetting('ai_image_gen_api_key', ''),
    image_gen_model: await settings.getSetting('ai_image_gen_model', 'dall-e-3'),
    image_gen_size: await settings.getSetting('ai_image_gen_size', '1024x1024'),
  };
}

async function loadSmartBotConfig(botId) {
  const rows = await db.rows(
    'SELECT config_key, config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=?',
    [botId, 'smart']
  );
  const cfg = {};
  rows.forEach(r => { cfg[r.config_key] = r.config_value; });
  return cfg;
}

/** 统一归一化 API Base(去掉结尾 / 与 /v1) */
function normalizeBase(s) {
  return String(s || '').replace(/\/+$/, '').replace(/\/v1\/?$/, '');
}

/**
 * 判断某份配置是否可用(有任一可用模型)。兼容两种形态:
 * - 站长模型:单组 { api_base, api_key, model }
 * - 自定义模型:{ models: [ {...}, ... ] }
 */
function cfgHasAI(cfg) {
  if (cfg.api_base && cfg.api_key) return true;
  if (Array.isArray(cfg.models) && cfg.models.some(m => m.api_base && m.api_key)) return true;
  return false;
}

// botId → 机器人拥有者的 ai_persona(缓存 10 秒,用户切换后快速生效)
const botPersonaCache = new Map();
async function getBotOwnerPersona(botId) {
  const cached = botPersonaCache.get(botId);
  if (cached && Date.now() - cached.ts < 10000) return cached.persona;
  const row = await db.row(
    'SELECT u.ai_persona FROM users u JOIN bots b ON u.id = b.user_id WHERE b.id = ?',
    [botId]
  );
  const persona = (row && row.ai_persona) || '';
  botPersonaCache.set(botId, { persona, ts: Date.now() });
  return persona;
}

/**
 * 解析智能助手的 AI 接口来源(模型选择):
 * - ai_source = 'owner'(默认)→ 使用管理员在后台统一配置的「站长模型」(全局 ai_api_base / ai_api_key / ai_model ...)
 * - ai_source = 'custom'      → 使用机器人自定义的「自定义模型」列表(支持多个,自动轮询)
 * - 兼容旧版:若未设置 ai_source 但 use_custom_ai=1 且旧单组 api_base/key 齐全 → 视为 custom(单模型)
 * 自定义模型列表来自 plugin_settings 的 ai_custom_models(JSON 数组),结构:
 *   [ { name, api_base, api_key, model }, ... ]
 * 若选择了 custom 却没有任何可用自定义模型 → 自动回退到站长模型。
 */
async function resolveSmartConfig(botId) {
  const global = await loadSmartConfig();
  const bot = await loadSmartBotConfig(botId);

  const source = bot.ai_source || (bot.use_custom_ai === '1' ? 'custom' : 'owner');
  const userPersona = await getBotOwnerPersona(botId);

  let result;
  if (source === 'custom') {
    const list = [];

    // ① 兼容旧版单组自定义配置(use_custom_ai 时代)
    if (bot.ai_api_base && bot.ai_api_key) {
      list.push({
        name: '默认模型',
        api_base: normalizeBase(bot.ai_api_base),
        api_key: bot.ai_api_key,
        model: (bot.ai_model || global.model || 'gpt-4o-mini').trim(),
      });
    }

    // ② 新版多模型列表(自定义模型 + 轮询)
    if (bot.ai_custom_models) {
      try {
        const arr = JSON.parse(bot.ai_custom_models);
        if (Array.isArray(arr)) {
          for (const m of arr) {
            if (m && m.api_base && m.api_key) {
              list.push({
                name: m.name || m.model || '自定义模型',
                api_base: normalizeBase(m.api_base),
                api_key: m.api_key,
                model: (m.model || global.model || 'gpt-4o-mini').trim(),
              });
            }
          }
        }
      } catch (e) {
        console.error('[smart] 解析 ai_custom_models 失败:', e.message);
      }
    }

    if (list.length) {
      result = {
        source: 'custom',
        custom: true,
        models: list,
        system_prompt: bot.ai_system_prompt || '',
        max_tokens: parseInt(bot.ai_max_tokens, 10) || global.max_tokens || 2000,
        temperature: parseFloat(bot.ai_temperature) || global.temperature || 0.7,
        transcribe_model: global.transcribe_model || 'whisper-1',
        stt_mode: global.stt_mode || 'local',
        stt_model: global.stt_model || 'Xenova/whisper-base',
        stt_mimo_api_key: global.stt_mimo_api_key || '',
        stt_mimo_base: global.stt_mimo_base || 'https://api.xiaomimimo.com',
        stt_mimo_model: global.stt_mimo_model || 'mimo-v2.5-asr',
        stt_mimo_lang: global.stt_mimo_lang || 'zh',
      };
    }
    // 选了 custom 但没有任何可用自定义模型 → 回退站长模型
  }

  if (!result) {
    // 全局「站长模型」若配置了多接口列表,则注入 models 供轮询调用;否则回退单组兜底
    result = { ...global, source: 'owner', custom: false };
    if (global.endpoints && global.endpoints.length) {
      result.models = global.endpoints;
      result.endpoint_strategy = global.endpoint_strategy || 'round_robin';
    }
  }

  result.persona = userPersona;
  return result;
}

// 开源版:Token 不限额
async function getUserTokenInfo(userId) {
  return { used: 0, limit: Number.MAX_SAFE_INTEGER };
}

async function addUserTokens(userId, tokens) {
  await db.exec('UPDATE users SET ai_tokens_used = ai_tokens_used + ? WHERE id = ?', [tokens, userId]);
  const d = new Date();
  const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  try {
    await db.exec(
      'INSERT INTO token_usage(user_id, day, tokens) VALUES(?, ?, ?) ON CONFLICT(user_id, day) DO UPDATE SET tokens = tokens + excluded.tokens',
      [userId, day, tokens]
    );
  } catch (e) { /* 热力图统计为非关键路径,失败忽略 */ }
}

/**
 * 图片生成(OpenAI 兼容 /v1/images/generations)。
 * @param {object} opts { botId, userId?, prompt, size?, quality?, n? }
 * @returns {Promise<{images:Array<{url?:string,b64_json?:string}>,quota:{used,limit}}>}
 */
async function callImageGeneration(opts) {
  const { botId, userId, prompt } = opts || {};
  if (!botId) throw new Error('缺少 botId');
  if (!prompt) throw new Error('缺少图片描述');

  // 使用独立的生图 API 配置(不引用 AI 对话接口)
  const global = await loadSmartConfig();
  if (!global.image_gen_enabled || global.image_gen_enabled !== '1') throw new Error('图片生成功能未启用');
  if (!global.image_gen_api_base || !global.image_gen_api_key) throw new Error('生图 API 未配置(缺少 API Base 或 API Key)');

  const imageModel = global.image_gen_model || 'dall-e-3';
  const imageSize = opts.size || global.image_gen_size || '1024x1024';
  const imageQuality = opts.quality || 'standard';
  const n = opts.n || 1;

  const base = global.image_gen_api_base.replace(/\/+$/, '');
  const reqBody = {
    model: imageModel,
    prompt,
    n,
    size: imageSize,
    quality: imageQuality,
    // 要求返回 base64 内联数据:图片字节走生图 API 网关(可访问),
    // 避免再去下载被墙的图床域名(如 cloudflarer2.nananobanana.com)导致转发失败。
    response_format: 'b64_json',
  };

  console.log('[smart] 图片生成请求:', JSON.stringify({ api_base: base, model: imageModel, size: imageSize, prompt: prompt.slice(0, 80) }));
  // 注意:原生 fetch 不支持 timeout 选项(会被忽略),大图生成常需 80~150s,
  // 这里改用 axios 并设 3 分钟真实超时,避免上游网关默认超时导致"服务暂时不可用"。
  const t0 = Date.now();
  let resp;
  try {
    const { default: axios } = await import('axios');
    const res = await axios.post(base + '/v1/images/generations', reqBody, {
      headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + global.image_gen_api_key },
      timeout: 180000,
      validateStatus: () => true,
    });
    console.log('[smart] 图片生成响应:', res.status, '耗时', ((Date.now() - t0) / 1000).toFixed(1) + 's');
    if (res.status < 200 || res.status >= 300) {
      const errBody = typeof res.data === 'string' ? res.data : JSON.stringify(res.data || {});
      throw new Error(`HTTP ${res.status} ${errBody.slice(0, 200)}`);
    }
    resp = res.data;
  } catch (e) {
    const secs = ((Date.now() - t0) / 1000).toFixed(1);
    if (e.code === 'ECONNABORTED' || /timeout/i.test(e.message || '')) {
      throw new Error(`生图超时(已等待 ${secs}s,大尺寸图片生成较慢,请稍后重试或换用 1024x1024 尺寸)`);
    }
    throw new Error('图片生成失败: ' + e.message);
  }

  // 记录生图日志(开源版不扣额度)
  if (userId) {
    await db.logImageGen(userId, prompt, imageModel, imageSize, n);
  }

  const images = (resp.data || []).map(d => ({ url: d.url, b64_json: d.b64_json }));
  return { images, quota: null };
}

// ==================== IMA 配置和 API ====================
// 复用 ima-knowledge 插件导出的公共函数,避免两套 IMA 实现分叉
const imaPlugin = require('../ima-knowledge');

async function loadImaConfig(botId) {
  const rows = await db.rows(
    'SELECT config_key, config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=?',
    [botId, 'ima-knowledge']
  );
  const cfg = {};
  rows.forEach(r => { cfg[r.config_key] = r.config_value; });
  return { client_id: cfg.client_id || '', api_key: cfg.api_key || '' };
}

// ==================== Function 定义 ====================
const TOOLS = [
  {
    type: 'function',
    function: {
      name: 'search_knowledge',
      description: '搜索用户的IMA知识库。当用户想查找某个文档、笔记、记录、合同、会议纪要、供应商资料、工作文件等任何知识库中的内容时调用此函数。',
      parameters: {
        type: 'object',
        properties: {
          query: { type: 'string', description: '搜索关键词,提取用户问题中最关键的信息作为搜索词' }
        },
        required: ['query']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'set_reminder',
      description: '设置一个定时提醒。当用户表达"提醒我"、"帮我设置提醒"、"记住"、"X分钟/小时后叫我/提醒"、"明天X点叫我"、"每天X点提醒"等意图时调用。',
      parameters: {
        type: 'object',
        properties: {
          time_desc: { type: 'string', description: '时间描述。必须转换为标准格式,如"5分钟后"、"30分钟后"、"1小时后"、"明天上午9点"、"下午3点"、"每天8点"、"每周一9点"、"2026-12-31 14:00"' },
          content: { type: 'string', description: '提醒的具体内容' }
        },
        required: ['time_desc', 'content']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'list_reminders',
      description: '查看当前所有待执行的提醒列表。当用户问"有什么提醒"、"查看提醒"、"我的提醒"、"还有哪些提醒"时调用。',
      parameters: { type: 'object', properties: {} }
    }
  },
  {
    type: 'function',
    function: {
      name: 'delete_reminder',
      description: '删除一个提醒。当用户说"取消提醒"、"删除提醒"、"去掉X号提醒"时调用。如果用户未指定编号,先调用list_reminders让用户确认。',
      parameters: {
        type: 'object',
        properties: {
          id: { type: 'integer', description: '要删除的提醒编号ID' }
        },
        required: ['id']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'get_daily_briefing',
      description: '获取今日新闻简报。当用户说"早报"、"新闻"、"简报"、"今天有什么新闻"、"每日新闻"时调用。',
      parameters: { type: 'object', properties: {} }
    }
  },
  {
    type: 'function',
    function: {
      name: 'get_random_image',
      description: '给用户发送一张随机美图/图片。当用户说"来张图"、"随机图片"、"发张美图"、"来点图片"时调用。',
      parameters: { type: 'object', properties: {} }
    }
  },
  {
    type: 'function',
    function: {
      name: 'create_note',
      description: '在IMA知识库中创建一篇新笔记。当用户说"记录一下"、"帮我记一下"、"写个笔记"、"保存到知识库"、"备忘"、"新建笔记"时调用。',
      parameters: {
        type: 'object',
        properties: {
          content: { type: 'string', description: '笔记正文内容,使用Markdown格式' },
          title: { type: 'string', description: '笔记标题(可选),如果用户没有提供就用内容第一句作为标题' }
        },
        required: ['content']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'save_url_to_knowledge',
      description: '将一个或多个网页链接/公众号文章/图片链接保存到IMA知识库。图片链接会作为图片文件存入,网页链接作为网页导入。当用户说"把这个链接保存"、"收藏这个网页"、"保存这篇文章到知识库"、"存这张图到知识库"时调用。',
      parameters: {
        type: 'object',
        properties: {
          urls: { type: 'array', items: { type: 'string' }, description: '要保存的URL列表' },
          knowledge_base_id: { type: 'string', description: '目标知识库ID(可选),不填则保存到第一个可用知识库' }
        },
        required: ['urls']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'write_knowledge',
      description: '将一段文本/内容直接写入 IMA 知识库(区别于保存网页链接)。当用户说"把这段话存到知识库"、"记到知识库"、"保存这段文字"、"写入知识库"等意图时调用。',
      parameters: {
        type: 'object',
        properties: {
          title: { type: 'string', description: '内容标题' },
          content: { type: 'string', description: '要写入的文本内容' }
        },
        required: ['title', 'content']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'save_image_to_knowledge',
      description: '把用户最近发送的图片保存到 IMA 知识库。仅当用户明确表示要保存图片时才调用,例如"把(刚才/这张)图片存到知识库"、"保存这张图片"、"存图到知识库"、"收藏这张图"。如果用户只是发图片没有说要保存,不要调用。',
      parameters: { type: 'object', properties: {} }
    }
  },
  {
    type: 'function',
    function: {
      name: 'search_note',
      description: '搜索用户的 IMA 笔记。当用户想查找某篇笔记,或问"我有没有记过XX"、"找一下那篇笔记"时调用。',
      parameters: {
        type: 'object',
        properties: {
          query: { type: 'string', description: '搜索关键词' }
        },
        required: ['query']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'read_note',
      description: '读取某篇 IMA 笔记的完整内容。当用户说"读一下那篇笔记"、"给我看XX笔记全文"并提供了笔记ID时调用;如果不知道ID,先调用 search_note 获取。',
      parameters: {
        type: 'object',
        properties: {
          note_id: { type: 'string', description: '笔记ID,可从 search_note 结果中获取' }
        },
        required: ['note_id']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'generate_image',
      description: '使用 AI 生成一张图片。当用户说"帮我画一张"、"生成一张图"、"画个XX"、"做张XX的图"、"生成图片"、"AI画图"、"绘图"等想要创作图片的意图时调用。描述要详细、具体(风格、场景、色彩、氛围等)。每次生成会消耗用户生图额度。',
      parameters: {
        type: 'object',
        properties: {
          prompt: { type: 'string', description: '图片生成的详细描述(英文效果更好),包含主题、风格、场景、色彩、氛围等细节' },
          size: { type: 'string', description: '图片尺寸,可选 1024x1024、1792x1024、1024x1792,默认 1024x1024' },
        },
        required: ['prompt']
      }
    }
  }
];

// ==================== 通用插件工具对接 ====================
/**
 * 收集当前机器人所有已启用插件声明的 AI 工具(通用互通协议)。
 * 任何插件只要在 meta.aiTools(或导出 aiTools)里声明 OpenAI function schema,
 * 并导出 handleAiTool(name, args, ctx),就会被自动注册进 smart 的 function calling,
 * 无需修改 smart 本身。详见 docs/插件开发指南.md「对接智能助手」。
 * @returns {Promise<{tools:Array, handlers:Object}>}
 */
async function collectPluginTools(botId) {
  try {
    const pluginsLib = require('../../lib/plugins');
    const mods = await pluginsLib.getEnabledModules(botId);
    const tasks = mods.map(async (mod) => {
      const out = { tools: [], handlers: {} };
      if (!mod || !mod.meta || mod.meta.id === 'smart') return out; // 跳过自己

      const handler = mod.handleAiTool || mod.executeAiTool;

      // 静态 aiTools 声明(meta.aiTools 数组)
      const declared = mod.meta.aiTools || mod.aiTools;
      if (Array.isArray(declared) && declared.length > 0 && typeof handler === 'function') {
        for (const t of declared) {
          const fnName = t && t.function && t.function.name;
          if (!fnName || out.handlers[fnName]) continue;
          out.tools.push(t);
          out.handlers[fnName] = handler;
        }
      }

      // 动态 aiTools(getAiTools(botId) → Promise<Array>)—— MCP、Skill 等
      if (typeof mod.getAiTools === 'function' && typeof handler === 'function') {
        try {
          const dynamicTools = await mod.getAiTools(botId);
          if (Array.isArray(dynamicTools) && dynamicTools.length > 0) {
            for (const t of dynamicTools) {
              const fnName = t && t.function && t.function.name;
              if (!fnName || out.handlers[fnName]) continue;
              out.tools.push(t);
              out.handlers[fnName] = handler;
            }
          }
        } catch (e) {
          console.error('[smart] 收集动态 AI 工具失败 (' + mod.meta.id + '):', e.message);
        }
      }
      return out;
    });
    const results = await Promise.all(tasks);
    const tools = [];
    const handlers = {};
    for (const r of results) {
      for (const t of r.tools) {
        const fnName = t && t.function && t.function.name;
        if (fnName && !handlers[fnName]) {
          tools.push(t);
          handlers[fnName] = r.handlers[fnName];
        }
      }
    }
    return { tools, handlers };
  } catch (e) {
    console.error('[smart] 收集插件 AI 工具失败:', e.message);
    return { tools: [], handlers: {} };
  }
}

/**
 * 收集所有插件的系统提示扩充(如 Skill 插件的 getCombinedPrompt)。
 * 任何已启用插件只要导出 getCombinedPrompt(botId, peerId) → string,就会被附加到 AI 系统提示中。
 * peerId 用于按用户注入(如用户画像)。
 */
async function collectPluginPrompts(botId, peerId) {
  try {
    const pluginsLib = require('../../lib/plugins');
    const mods = await pluginsLib.getEnabledModules(botId);
    const tasks = [];
    for (const mod of mods) {
      if (!mod || !mod.meta) continue;
      if (mod.meta.id === 'smart' || mod.meta.id === 'mcp') continue; // 跳过自己 & MCP(只提供工具,不改变 prompt)
      if (typeof mod.getCombinedPrompt === 'function') {
        tasks.push(
          Promise.resolve()
            .then(() => mod.getCombinedPrompt(botId, peerId))
            .then(p => (p && typeof p === 'string' && p.trim()) ? p.trim() : null)
            .catch(e => { console.error('[smart] 收集插件提示失败 (' + mod.meta.id + '):', e.message); return null; })
        );
      }
    }
    const results = await Promise.all(tasks);
    return results.filter(Boolean).join('\n');
  } catch (e) {
    console.error('[smart] 收集插件提示失败:', e.message);
    return '';
  }
}

// ==================== 工具执行器 ====================
async function executeTool(toolName, args, ctx, pluginHandlers) {
  const botId = ctx.bot.id;
  const peerId = ctx.msg.peer_id;

  switch (toolName) {

    case 'search_knowledge': {
      const imaCfg = await loadImaConfig(botId);
      if (!imaCfg.client_id || !imaCfg.api_key) {
        return '知识库功能尚未配置。请在机器人管理面板中设置 IMA 知识库的 Client ID 和 API Key。获取方式:登录 ima.qq.com → 设置 → API 管理。';
      }
      const query = (args.query || '').trim();
      if (!query) return '请提供搜索关键词。';
      try {
        const bases = await imaPlugin.searchKnowledgeBases(imaCfg, query);
        if (!bases.length) return '未找到匹配的知识库,请尝试其他关键词。';
        const kbId = bases[0].id;
        const kbName = bases[0].name || kbId;
        const results = await imaPlugin.searchInKnowledge(imaCfg, kbId, query);
        if (!results.length) return `在知识库「${kbName}」中未找到与「${query}」相关的内容。`;

        const items = results.slice(0, 5).map((item, i) => {
          let snippet = (item.highlight_content || '').replace(/<\/?em>/g, '').replace(/\s+/g, ' ').trim();
          if (snippet.length > 150) snippet = snippet.slice(0, 150) + '…';
          return `${i + 1}. 【${item.title || '无标题'}】${snippet ? '\n   ' + snippet : ''}`;
        });

        let reply = `[知识库] 在「${kbName}」中搜索「${query}」,找到 ${results.length} 条结果:\n\n${items.join('\n\n')}`;
        if (results.length > 5) reply += `\n\n… 还有 ${results.length - 5} 条结果。`;
        return reply;
      } catch (e) {
        return '搜索知识库时出错:' + e.message;
      }
    }

    case 'set_reminder': {
      const timeDesc = (args.time_desc || '').trim();
      const content = (args.content || '').trim();
      if (!timeDesc || !content) return '请同时提供时间和提醒内容。';

      const parsed = parseTime(timeDesc);
      if (!parsed) return `无法解析时间「${timeDesc}」。支持格式:5分钟后、1小时后、明天9点、下午3点、每天8点、每周一9点、2026-07-20 14:30`;

      const now = Math.floor(Date.now() / 1000);
      if (parsed.target <= now) return `时间「${timeDesc}」已过,请设置未来时间。`;

      const ctxToken = ctx.msg.context_token || null;
      await db.exec(
        'INSERT INTO reminders (bot_id, peer_id, context_token, content, remind_at, repeat_type, repeat_rule, created_at, fired) VALUES (?,?,?,?,?,?,?,?,0)',
        [botId, peerId, ctxToken, content, parsed.target, parsed.repeatType, parsed.repeatRule || '', now]
      );

      const dt = new Date(parsed.target * 1000);
      const ds = dt.toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
      const tag = parsed.repeatType === 'daily' ? ',每天重复' : parsed.repeatType === 'weekly' ? ',每周重复' : '';
      return `[OK] 提醒已设置:${ds}${tag}\n内容:${content}`;
    }

    case 'list_reminders': {
      const items = await db.rows(
        'SELECT id, content, remind_at, repeat_type FROM reminders WHERE bot_id=? AND peer_id=? AND fired=0 ORDER BY remind_at ASC LIMIT 20',
        [botId, peerId]
      );
      if (!items.length) return '当前没有待执行的提醒。';
      const lines = items.map((r, i) => {
        const dt = new Date(r.remind_at * 1000);
        const ds = dt.toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
        const tag = r.repeat_type === 'daily' ? '[每天] ' : r.repeat_type === 'weekly' ? '[每周] ' : '';
        return `${r.id}. ${tag}${ds} — ${r.content}`;
      });
      return '[提醒] 待执行提醒:\n' + lines.join('\n') + '\n\n发送「删除提醒 编号」可取消。';
    }

    case 'delete_reminder': {
      const rid = parseInt(args.id, 10);
      if (!rid || rid < 1) return '请提供有效的提醒编号。';
      const item = await db.row('SELECT id, content FROM reminders WHERE id=? AND bot_id=? AND peer_id=?', [rid, botId, peerId]);
      if (!item) return `未找到编号为 ${rid} 的提醒。`;
      await db.exec('DELETE FROM reminders WHERE id = ?', [rid]);
      return `[OK] 已删除提醒 #${rid}:「${item.content}」`;
    }

    case 'get_daily_briefing': {
      try {
        const imageUrl = await fetchBriefing();
        if (!imageUrl) {
          return '暂时无法获取新闻简报,请稍后再试。';
        }
        const ext = imageUrl.split('?')[0].split('.').pop().toLowerCase() || 'jpg';
        const tmpName = 'briefing_smart_' + Date.now() + '.' + ext;
        if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
        const tmpPath = path.join(TMP_DIR, tmpName);
        const response = await axios.get(imageUrl, {
          timeout: 15000, responseType: 'arraybuffer',
          headers: {
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
            'Referer': 'https://v2.xxapi.cn/',
          },
        });
        const buf = Buffer.from(response.data);
        if (buf.length < 100) {
          console.error('[smart] 简报图片过小:', buf.toString('utf-8').substring(0, 200));
          return '简报图片获取异常,请稍后再试。';
        }
        fs.writeFileSync(tmpPath, buf);
        try {
          await ctx.sendMedia(tmpPath, 'image');
        } catch (e) {
          console.error('[smart] sendMedia 简报失败:', e.message);
          return '简报图片发送失败,请稍后再试。';
        }
        try { fs.unlinkSync(tmpPath); } catch (_) { /* ignore */ }
        return '已发送今日新闻简报图片给你!';
      } catch (e) {
        return '获取新闻简报时出错:' + e.message;
      }
    }

    case 'get_random_image': {
      try {
        const { data: resp } = await axios.get('https://api.unmz.net/free/api/images/girl/getRandomGirlUrl', {
          timeout: 10000, headers: { 'User-Agent': 'Mozilla/5.0' },
        });
        const urls = resp && resp.data;
        if (!urls || !Array.isArray(urls) || urls.length === 0) {
          return '暂时未能获取到图片,请稍后再试。';
        }
        const imgUrl = urls[0];
        const ext = imgUrl.split('?')[0].split('.').pop().slice(0, 4) || 'jpg';
        const tmpName = 'randimg_smart_' + Date.now() + '.' + ext;
        if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
        const tmpPath = path.join(TMP_DIR, tmpName);
        const { data: imgBuf } = await axios.get(imgUrl, {
          timeout: 15000, responseType: 'arraybuffer', headers: { 'User-Agent': 'Mozilla/5.0' },
        });
        fs.writeFileSync(tmpPath, Buffer.from(imgBuf));
        try {
          await ctx.sendMedia(tmpPath, 'image');
        } catch (e) {
          console.error('[smart] sendMedia 失败:', e.message);
        }
        try { fs.unlinkSync(tmpPath); } catch (_) { /* ignore */ }
        return '已发送一张随机美图给你!';
      } catch (e) {
        return '获取图片时出错:' + e.message;
      }
    }

    case 'create_note': {
      const imaCfg = await loadImaConfig(botId);
      if (!imaCfg.client_id || !imaCfg.api_key) {
        return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
      }
      const content = (args.content || '').trim();
      if (!content) return '请提供要记录的笔记内容。';
      const title = args.title || content.split(/[\n\r]/)[0].slice(0, 50);
      try {
        // 1) 创建笔记
        const note = await imaPlugin.createNote(imaCfg, content);
        const docId = note.doc_id || note.note_id;
        // 2) 获取可用知识库,绑定笔记到知识库
        let kbName = '';
        if (docId) {
          const bases = await imaPlugin.getKnowledgeBaseList(imaCfg);
          if (bases.length) {
            const kb = bases[0];
            kbName = kb.name || '';
            await imaPlugin.addKnowledge(imaCfg, kb.id, title, 11, { note_info: { content_id: docId } });
          }
        }
        const kbSuffix = kbName ? `\n已同步到知识库:${kbName}` : '';
        return `[OK] 笔记已创建成功!\n标题:${title}${kbSuffix}`;
      } catch (e) {
        return '创建笔记失败:' + e.message;
      }
    }

    case 'save_url_to_knowledge': {
      const imaCfg = await loadImaConfig(botId);
      if (!imaCfg.client_id || !imaCfg.api_key) {
        return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
      }
      const rawUrls = args.urls || [];
      // 处理 args.urls 可能是字符串的情况(AI 有时不严格输出数组)
      const urls = Array.isArray(rawUrls) ? rawUrls : (typeof rawUrls === 'string' ? [rawUrls] : []);
      const validUrls = urls.map(u => String(u).trim()).filter(u => u && /^https?:\/\/.+/.test(u));
      if (validUrls.length === 0) return '请提供有效的网页链接(以 http:// 或 https:// 开头)。';
      try {
        // 获取可用知识库
        let kbId = args.knowledge_base_id;
        if (!kbId) {
          const bases = await imaPlugin.getKnowledgeBaseList(imaCfg);
          if (!bases.length) return '没有找到可用的知识库,请先在 IMA 中创建一个知识库。';
          kbId = bases[0].id;
        }
        // 图片URL → 作为图片文件存入;其余 → 作为网页链接导入
        const imageUrls = validUrls.filter(u => imaPlugin.isImageUrl(u));
        const linkUrls = validUrls.filter(u => !imaPlugin.isImageUrl(u));
        if (linkUrls.length) await imaPlugin.importUrls(imaCfg, kbId, linkUrls);
        let imgSaved = 0;
        for (const u of imageUrls) {
          try {
            const img = await imaPlugin.downloadImageFromUrl(u);
            await imaPlugin.addKnowledgeImage(imaCfg, kbId, img);
            imgSaved++;
          } catch (e) { /* 单张失败不影响其他 */ }
        }
        const bits = [];
        if (linkUrls.length) bits.push(linkUrls.length + ' 个链接');
        if (imgSaved) bits.push(imgSaved + ' 张图片');
        return `[OK] 已保存到知识库(${bits.join(',') || '0'})!`;
      } catch (e) {
        return '保存失败:' + e.message;
      }
    }

    case 'write_knowledge': {
      const imaCfg = await loadImaConfig(botId);
      if (!imaCfg.client_id || !imaCfg.api_key) {
        return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
      }
      const title = (args.title || '').trim();
      const content = (args.content || '').trim();
      if (!content) return '请提供要写入的内容。';
      try {
        const bases = await imaPlugin.getKnowledgeBaseList(imaCfg);
        if (!bases.length) return '没有可用的知识库,请先在 IMA 中创建一个知识库。';
        const kb = bases[0];
        const kbName = kb.name || kb.id;
        await imaPlugin.addKnowledgeText(imaCfg, kb.id, title || content.slice(0, 20), content);
        return `[OK] 已写入知识库「${kbName}」!`;
      } catch (e) {
        return '写入知识库失败:' + e.message;
      }
    }

    case 'save_image_to_knowledge': {
      const imaCfg = await loadImaConfig(botId);
      if (!imaCfg.client_id || !imaCfg.api_key) {
        return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
      }
      try {
        const img = await imaPlugin.loadLastInboundImage(ctx.bot, peerId);
        if (!img || !img.buffer || !img.buffer.length) {
          return '没有找到你最近发送的图片。请先发送一张图片,再说"存到知识库"。';
        }
        const bases = await imaPlugin.getKnowledgeBaseList(imaCfg);
        if (!bases.length) return '没有可用的知识库,请先在 IMA 中创建一个知识库。';
        const kb = bases[0];
        await imaPlugin.addKnowledgeImage(imaCfg, kb.id, img);
        return `[OK] 已把图片存入知识库「${kb.name || kb.id}」!`;
      } catch (e) {
        return '保存图片到知识库失败:' + e.message;
      }
    }

    case 'search_note': {
      const imaCfg = await loadImaConfig(botId);
      if (!imaCfg.client_id || !imaCfg.api_key) {
        return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
      }
      const query = (args.query || '').trim();
      if (!query) return '请提供搜索关键词。';
      try {
        const notes = await imaPlugin.searchNotes(imaCfg, query);
        if (!notes.length) return `未找到与「${query}」相关的笔记。`;
        const items = notes.slice(0, 8).map((n, i) =>
          `${i + 1}. ${n.title || n.name || '无标题'}  (id: ${n.note_id || n.id || ''})`
        );
        return `[Notes] 笔记搜索「${query}」找到 ${notes.length} 篇:\n${items.join('\n')}\n\n可调用 read_note 并传入 id 读取某篇全文。`;
      } catch (e) {
        return '搜索笔记失败:' + e.message;
      }
    }

    case 'read_note': {
      const imaCfg = await loadImaConfig(botId);
      if (!imaCfg.client_id || !imaCfg.api_key) {
        return 'IMA 知识库功能尚未配置。请在机器人管理面板 → IMA 知识库设置中填入 Client ID 和 API Key。';
      }
      const noteId = (args.note_id || '').trim();
      if (!noteId) return '请提供笔记ID。可先调用 search_note 查找。';
      try {
        const note = await imaPlugin.getNote(imaCfg, noteId);
        const title = note.title || (note.doc && note.doc.basic_info && note.doc.basic_info.title) || '无标题';
        const content = note.content || (note.doc && note.doc.content) || '';
        if (!content) return `[Note] 《${title}》没有可读取的正文内容。`;
        if (content.length > 1500) {
          return `[Note] 《${title}》(共 ${content.length} 字,节选前 1500 字)\n\n${content.slice(0, 1500)}\n\n… 内容较长已截断,可在 IMA 中查看完整笔记。`;
        }
        return `[Note] 《${title}》\n\n${content}`;
      } catch (e) {
        return '读取笔记失败:' + e.message;
      }
    }

    case 'generate_image': {
      return await handleGenerateImageTool(args, ctx);
    }

    default:
      // 交给声明了该工具的第三方插件处理(通用互通协议)
      if (pluginHandlers && typeof pluginHandlers[toolName] === 'function') {
        try {
          const r = await pluginHandlers[toolName](toolName, args, ctx);
          return String(r == null ? '' : r);
        } catch (e) {
          console.error('[smart] 插件工具执行失败:', toolName, e.message);
          return '工具「' + toolName + '」执行失败:' + e.message;
        }
      }
      return `未知工具:${toolName}`;
  }
}

/** 图片生成工具处理(单独导出,方便复用) */
async function handleGenerateImageTool(args, ctx) {
  const botId = ctx.bot.id;
  const userId = ctx.bot.user_id;
  const prompt = (args.prompt || '').trim();
  const size = args.size || '1024x1024';

  if (!prompt) return '请提供图片描述。';

  // 检查生图功能是否启用
  const globalCfg = await loadSmartConfig();
  if (globalCfg.image_gen_enabled !== '1') return '图片生成功能未启用,请联系管理员开启。';

  try {
    const result = await callImageGeneration({ botId, userId, prompt, size });
    if (result.images && result.images.length > 0) {
      // 发送图片
      const { default: axios } = await import('axios');
      const fs = require('fs');
      const path = require('path');
      const os = require('os');
      const sentUrls = [];
      for (const img of result.images) {
        // 优先用 base64 内联数据(无需访问被墙图床域名),无 base64 才尝试下载 url
        if (img.b64_json) {
          // base64 图片直接存文件发送
          try {
            const tmpDir = path.join(os.tmpdir(), 'ngbot_img');
            if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
            const fname = 'gen_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + '.png';
            const fpath = path.join(tmpDir, fname);
            fs.writeFileSync(fpath, Buffer.from(img.b64_json, 'base64'));
            await ctx.sendMedia(fpath, 'image', fname);
            try { fs.unlinkSync(fpath); } catch (_) {}
            sentUrls.push('(base64 内联图片)');
          } catch (e) {
            console.error('[smart] 发送 base64 生成图片失败:', e.message);
          }
        } else if (img.url) {
          try {
            const tmpDir = path.join(os.tmpdir(), 'ngbot_img');
            if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
            const fname = 'gen_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + '.png';
            const fpath = path.join(tmpDir, fname);
            const res = await axios({ method: 'GET', url: img.url, responseType: 'arraybuffer', timeout: 60000 });
            fs.writeFileSync(fpath, Buffer.from(res.data));
            await ctx.sendMedia(fpath, 'image', fname);
            try { fs.unlinkSync(fpath); } catch (_) {}
            sentUrls.push(img.url);
          } catch (e) {
            console.error('[smart] 下载/发送生成图片失败:', e.message);
          }
        }
      }
      const q = result.quota;
      return `已为你生成图片!${sentUrls.length > 0 ? '\n' + sentUrls.join('\n') : ''}${q ? '\n剩余生图次数:' + (q.limit - q.used) + '/' + q.limit : ''}`;
    }
    return '图片生成失败,服务器未返回任何图片。';
  } catch (e) {
    return '图片生成失败:' + e.message;
  }
}

// ==================== 噪声消息过滤 ====================
// 过滤无意义的随机字母、乱码、键盘乱按等,避免浪费 Token
function isNoiseMessage(text) {
  const len = text.length;

  // 1-2个字符:只过滤纯符号/纯数字(保留中文、英文、常用语气词)
  if (len <= 2) {
    // 中文单字/双字(嗯、好、哦、哈哈、呵呵 等)不拦截
    if (/[\u4e00-\u9fff]/.test(text)) return false;
    // 英文短词(hi, ok, no, yo 等)不拦截
    if (/^[a-zA-Z]{1,2}$/.test(text)) return false;
    // 纯数字/纯符号/纯表情 → 过滤
    if (/^[\d\W_]+$/.test(text)) return true;
    return false;
  }

  // 全是同一个字符重复(aaaa, 1111, ......)
  if (new Set(text).size <= 1) return true;

  // 纯数字无意义长串(超过5位且无任何分隔符/中文/字母)
  if (/^\d{5,}$/.test(text)) return true;

  // 高熵无意义字符串判定:
  // - 纯 ASCII 字母/数字/符号,无空格、无中文、无标点分隔
  // - 长度 >= 8 且 字符熵 > 0.7(即几乎所有字符都不一样)
  const asciiOnly = /^[\x00-\x7F]+$/.test(text);
  if (asciiOnly && len >= 8 && !/[ .,!?;:,。!?;:\-\n]/.test(text)) {
    const uniqueRatio = new Set(text).size / len;
    // 高唯一比 + 无空格/标点 → 大概率是随机乱码/base64/哈希之类
    if (uniqueRatio > 0.7) return true;
  }

  // 键盘乱按模式:交替的相邻键盘字母(如 asdfghjk, qwertyui)
  if (asciiOnly && len >= 5 && isKeyboardMashing(text)) return true;

  return false;
}

// 检测键盘横向/纵向乱按模式
function isKeyboardMashing(text) {
  const keyboardRows = [
    'qwertyuiop',
    'asdfghjkl',
    'zxcvbnm',
  ];
  const lower = text.toLowerCase();

  // 逐行检查是否大部分字符集中在同一行
  for (const row of keyboardRows) {
    let inRow = 0;
    for (const ch of lower) {
      if (row.includes(ch)) inRow++;
    }
    // 超过70%的字符都在同一行键盘上 → 乱按
    if (inRow / lower.length > 0.7) return true;
  }

  // 检查是否有连续4个以上键盘相邻字符
  for (let i = 0; i < lower.length - 3; i++) {
    const sub = lower.substring(i, i + 4);
    let adjacent = true;
    for (let j = 1; j < sub.length; j++) {
      let found = false;
      for (const row of keyboardRows) {
        const idx = row.indexOf(sub[j - 1]);
        if (idx >= 0 && (row[idx + 1] === sub[j] || row[idx - 1] === sub[j])) {
          found = true;
          break;
        }
      }
      if (!found) { adjacent = false; break; }
    }
    if (adjacent) return true;
  }

  return false;
}

// ==================== 默认系统提示词 ====================
const DEFAULT_SYSTEM_PROMPT = `你是一个智能助手,用户无需任何指令前缀,直接发送自然语言即可与你对话。

你具备以下能力:
- 识别用户发来的图片(认花、认物、认动物、看文字截图等)——这是自动完成的
- 识别用户发来的语音消息(微信语音会自动转成文字后交给你理解并回复)——这是自动完成的,你只需像处理普通文字一样回应
- 把用户最近发送的图片保存到 IMA 知识库(仅当用户明确说"把图片存到知识库""保存这张图"时,调用 save_image_to_knowledge;用户只发图片没说保存时绝不调用)
- 搜索用户的 IMA 知识库,查找文档、笔记、会议纪要、合同、供应商资料等
- 将文本/内容直接写入 IMA 知识库(用户说"存到知识库""记到知识库""写入知识库"时,调用 write_knowledge)
- 搜索用户的 IMA 笔记(用户说"我记过XX吗""找一下那篇笔记"时,调用 search_note)
- 读取某篇 IMA 笔记的完整内容(用户说"读一下那篇笔记""给我看全文"并提供笔记ID时,调用 read_note)
- 在 IMA 知识库中创建笔记(用户说"记录一下""记个笔记""保存到知识库"时)
- 将网页链接/公众号文章保存到 IMA 知识库(用户发链接并说"保存""收藏"时)

读取笔记的流程:当用户想读某篇笔记但没给 ID 时,先调用 search_note 用关键词找到笔记并拿到 id,再调用 read_note 读取全文。
- 设置定时提醒(支持相对时间、绝对时间、每天/每周重复)
- 查看和删除提醒
- 获取每日新闻简报
- 发送随机美图
- 日常对话

当用户的消息需要执行上述操作时,请调用对应的工具函数。获得工具返回结果后,用自然、友好的语言转述给用户。

重要规则:
- 如果用户没有明确说要执行某个操作,就当作普通对话处理,不要强行调用工具。
- 搜索知识库时,从用户的问题中提取最关键的搜索词。
- 设置提醒时,将用户的时间表述转换为标准格式(如"5分钟后"、"明天上午9点"、"每天8点"等)。
- 如果用户的消息语义不明确或像是随便发的无意义内容,可以简单回复一句引导用户说明需求,不要长篇大论。

始终保持简洁、有帮助、温暖的态度。回复不要太长。`;

// ==================== AI 人格预设 ====================
const PERSONAS = {
  '': '',
  'warm': '\n\n【说话风格】温柔体贴,像一位知心朋友。多用"呢""呀""哦"等语气词,回复中适当表达关心和共情。让人感到温暖和被理解。',
  'cool': '\n\n【说话风格】简洁干练,像一位高冷但靠谱的技术大佬。少说废话,一针见血,偶尔带点冷幽默。不废话不啰嗦。',
  'humorous': '\n\n【说话风格】幽默风趣,像一位段子手朋友。适当使用俏皮话、谐音梗和轻松的调侃。让人在对话中感到开心和放松。',
  'otaku': '\n\n【说话风格】二次元萌系,像一位元气满满的动漫伙伴。可以偶尔使用"喵""捏""ww""诶嘿"等口癖,语气活泼可爱。',
  'professional': '\n\n【说话风格】专业严谨,像一位资深顾问。表达条理清晰、有逻辑、有依据,但保持友好和耐心。像在和客户沟通。',
  'cute': '\n\n【说话风格】软萌可爱,像一只暖心小萌宠。说话带叠词和语气词,让人感到治愈。比如"好滴好滴~""知道啦~"这样的感觉。',
  'tsundere': '\n\n【说话风格】傲娇系,嘴上不饶人但内心很关心对方。表面嫌弃但实际很热心。比如"才...才不是因为关心你才帮你的呢!"',
};
function getPersonaText(persona) {
  if (!persona) return '';
  if (PERSONAS[persona]) return PERSONAS[persona];
  return '\n\n' + persona;
}

// ==================== AI 调用 ====================
const modelStats = require('../../lib/model-stats');

async function callAI(cfg, messages, tools, meta) {
  const t0 = Date.now();
  const body = {
    model: cfg.model,
    messages,
    max_tokens: cfg.max_tokens,
    temperature: cfg.temperature,
  };
  if (tools && tools.length > 0) {
    body.tools = tools;
    body.tool_choice = 'auto';
  }

  try {
    const resp = await axios.post(cfg.api_base + '/v1/chat/completions', body, {
      headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.api_key },
      timeout: 60000,
    });
    const u = resp.data && resp.data.usage;
    const latency = Date.now() - t0;
    try {
      modelStats.logModelCall({
        bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
        peer_id: meta && meta.peer_id || null,
        model: cfg.model,
        mode: 'chat',
        prompt_tokens: (u && u.prompt_tokens) || 0,
        completion_tokens: (u && u.completion_tokens) || 0,
        total_tokens: (u && u.total_tokens) || 0,
        ttft_ms: latency,
        latency_ms: latency,
        success: 1,
      });
    } catch (_) {}
    return resp.data;
  } catch (e) {
    try {
      modelStats.logModelCall({
        bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
        peer_id: meta && meta.peer_id || null,
        model: cfg.model,
        mode: 'chat',
        success: 0,
        error_msg: (e && e.message) || 'unknown',
        latency_ms: Date.now() - t0,
      });
    } catch (_) {}
    throw e;
  }
}

// ==================== 多模型轮询(负载均衡 + 故障转移) ====================
// 轮询游标:每次调用取 (cursor % n) 并自增,使多个自定义模型被均摊请求。
let modelCursor = 0;
function nextModelIndex(n) {
  if (n <= 1) return 0;
  const i = modelCursor % n;
  modelCursor = (modelCursor + 1) % n;
  return i;
}

/**
 * 带轮询 + 故障转移的 AI 调用。
 * - cfg 可以是单模型({api_base,api_key,model})或自定义多模型({models:[...]})
 * - 先按轮询游标选一个模型;若该模型报错或返回空,按顺序尝试其余模型(故障转移)
 * @returns {Promise<object>} OpenAI chat completion 响应
 */
async function callAIWithFallback(cfg, messages, tools, meta) {
  const models = cfg.models && cfg.models.length
    ? cfg.models
    : [{ api_base: cfg.api_base, api_key: cfg.api_key, model: cfg.model }];
  if (!models.length || !models[0].api_base || !models[0].api_key) {
    throw new Error('AI 未配置(无可用模型)');
  }
  const strategy = cfg.endpoint_strategy || 'round_robin';
  let start;
  if (strategy === 'random') {
    start = Math.floor(Math.random() * models.length);
  } else if (strategy === 'fallback') {
    start = 0; // 固定优先第一个,失败才顺序尝试其余(故障转移)
  } else { // round_robin
    start = nextModelIndex(models.length);
  }
  let lastErr = null;
  for (let k = 0; k < models.length; k++) {
    const idx = (start + k) % models.length;
    try {
      const r = await callAI(models[idx], messages, tools, meta);
      if (r && r.choices && r.choices[0] && r.choices[0].message) return r;
      lastErr = new Error('AI 返回空响应');
    } catch (e) {
      lastErr = e;
      console.error(`[smart] 模型「${models[idx].model}」调用失败,轮询下一个:`, e.message);
    }
  }
  throw lastErr || new Error('所有可用模型均调用失败');
}

// ==================== 图片识别(视觉模型) ====================
/**
 * 调用「视觉模型」识别图片内容(OpenAI 兼容的多模态 chat/completions)。
 * 消息 content 用数组形式携带 image_url(base64 data URL),非视觉模型会报错,由上层兜底。
 * @param {object} cfg     resolveSmartConfig 结果(单模型或多模型)
 * @param {string} dataUrl data:image/...;base64,... 形式的图片
 * @param {string} promptText 引导提示词
 * @returns {Promise<{content:string, usage:number}>}
 */
async function callVision(cfg, dataUrl, promptText, meta) {
  const models = cfg.models && cfg.models.length
    ? cfg.models
    : [{ api_base: cfg.api_base, api_key: cfg.api_key, model: cfg.model }];
  if (!models.length || !models[0].api_base || !models[0].api_key) {
    throw new Error('AI 未配置(无可用模型)');
  }
  const messages = [{
    role: 'user',
    content: [
      { type: 'text', text: promptText },
      { type: 'image_url', image_url: { url: dataUrl } },
    ],
  }];
  let lastErr = null;
  for (const m of models) {
    const t0 = Date.now();
    try {
      const body = {
        model: m.model,
        messages,
        max_tokens: cfg.max_tokens || 2000,
        temperature: cfg.temperature != null ? cfg.temperature : 0.7,
      };
      const resp = await axios.post(m.api_base + '/v1/chat/completions', body, {
        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + m.api_key },
        timeout: 120000,
      });
      const content = resp.data?.choices?.[0]?.message?.content;
      if (content && String(content).trim()) {
        const u = resp.data?.usage;
        const latency = Date.now() - t0;
        try {
          modelStats.logModelCall({
            bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
            peer_id: meta && meta.peer_id || null,
            model: m.model,
            mode: 'vision',
            prompt_tokens: (u && u.prompt_tokens) || 0,
            completion_tokens: (u && u.completion_tokens) || 0,
            total_tokens: (u && u.total_tokens) || 0,
            ttft_ms: latency,
            latency_ms: latency,
            success: 1,
          });
        } catch (_) {}
        return { content: String(content).trim(), usage: u?.total_tokens || 0 };
      }
      lastErr = new Error('视觉模型返回空响应');
    } catch (e) {
      lastErr = e;
      try {
        modelStats.logModelCall({
          bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
          peer_id: meta && meta.peer_id || null,
          model: m.model,
          mode: 'vision',
          success: 0,
          error_msg: (e && e.message) || 'unknown',
          latency_ms: Date.now() - t0,
        });
      } catch (_) {}
      console.error(`[smart] 视觉模型「${m.model}」识别失败:`, e.response?.data?.error?.message || e.message);
    }
  }
  throw lastErr || new Error('图片识别失败');
}

const IMAGE_RECOGNIZE_PROMPT = `请识别这张图片的内容并用中文简洁友好地描述:
- 如果是花卉/植物/动物/食物/物品,请说明它的名称(尽量给出具体品种),以及关键特征。
- 如果是文字/截图/文档,请概括其主要内容。
- 如果是风景/场景,请描述地点或场景类型。
控制在 150 字以内,不要啰嗦。`;

/**
 * 处理入站图片消息:下载解密 → 视觉模型识别 → 回复识别结果。
 * 注意:不会自动存知识库;如需保存,用户需明确说「存到知识库」(走 save_image_to_knowledge 工具)。
 * @returns {Promise<boolean>} 是否已处理
 */
async function handleImageMessage(msg, ctx) {
  const botId = ctx.bot.id;
  const userId = ctx.bot.user_id;

  const cfg = await resolveSmartConfig(botId);
  if (!cfgHasAI(cfg)) return false; // AI 未配置,放行给其他插件

  const tokenInfo = await getUserTokenInfo(userId);
  if (tokenInfo.used >= tokenInfo.limit) return false; // 额度用尽,放行

  // 下载并解密用户发来的图片(复用 ima-knowledge 的 CDN 下载 + AES 解密)
  let img;
  try {
    img = await imaPlugin.downloadInboundImage(ctx.bot, msg.content);
  } catch (e) {
    console.error('[smart] 图片下载失败:', e.message);
    return false;
  }
  if (!img || !img.buffer || !img.buffer.length) return false;

  const mimeMap = {
    jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
    bmp: 'image/bmp', webp: 'image/webp',
  };
  const mime = mimeMap[(img.ext || 'jpg').toLowerCase()] || 'image/jpeg';
  const dataUrl = 'data:' + mime + ';base64,' + img.buffer.toString('base64');

  try {
    const { content, usage } = await callVision(cfg, dataUrl, IMAGE_RECOGNIZE_PROMPT, { bot_id: botId, peer_id: msg.peer_id });
    if (usage > 0) await addUserTokens(userId, usage);

    // 若已配置 IMA 知识库,提示用户可存图(图片不再自动存)
    let hint = '';
    try {
      const imaCfg = await loadImaConfig(botId);
      if (imaCfg.client_id && imaCfg.api_key) {
        hint = '\n\n[Tip] 如需保存这张图片到知识库,请回复「把图片存到知识库」。';
      }
    } catch (_) { /* ignore */ }

    await ctx.sendText('[Ref] 图片识别结果:\n' + content + hint);
    return true;
  } catch (e) {
    console.error('[smart] 图片识别失败:', e.message);
    await ctx.sendText('[WARN] 图片识别失败:当前 AI 模型可能不支持图片识别,请联系管理员配置支持视觉的模型(如 gpt-4o、gpt-4o-mini、qwen-vl 等)。');
    return true;
  }
}

// ==================== 语音识别(SILK 解码 + Whisper 转写) ====================

/** PCM(s16le) → WAV 封装(Whisper 可识别) */
function pcmToWav(pcm, sampleRate, channels, bitsPerSample) {
  const byteRate = sampleRate * channels * (bitsPerSample / 8);
  const blockAlign = channels * (bitsPerSample / 8);
  const dataSize = pcm.length;
  const buf = Buffer.alloc(44 + dataSize);
  buf.write('RIFF', 0);
  buf.writeUInt32LE(36 + dataSize, 4);
  buf.write('WAVE', 8);
  buf.write('fmt ', 12);
  buf.writeUInt32LE(16, 16);
  buf.writeUInt16LE(1, 20); // PCM
  buf.writeUInt16LE(channels, 22);
  buf.writeUInt32LE(sampleRate, 24);
  buf.writeUInt32LE(byteRate, 28);
  buf.writeUInt16LE(blockAlign, 32);
  buf.writeUInt16LE(bitsPerSample, 34);
  buf.write('data', 36);
  buf.writeUInt32LE(dataSize, 40);
  pcm.copy(buf, 44);
  return buf;
}

/**
 * 把微信入站语音(解密后的原始字节)转成 Whisper 可识别的音频。
 * - 已是 WAV → 直接返回
 * - SILK 编码(微信语音常见)→ 解码为 PCM 再封装 WAV
 * - 其它(AMR 等)→ 原样返回,交给识别接口尝试
 * @returns {{buffer:Buffer, ext:string, mime:string}}
 */
async function voiceToWav(buffer, ref) {
  if (silk.isWav(buffer)) return { buffer, ext: 'wav', mime: 'audio/wav' };
  if (silk.isSilk(buffer)) {
    const sampleRate = (ref && ref.sample_rate) || 24000;
    const { data: pcm } = await silk.decode(buffer, sampleRate);
    if (!pcm || !pcm.length) throw new Error('SILK 解码结果为空');
    return { buffer: pcmToWav(Buffer.from(pcm), sampleRate, 1, 16), ext: 'wav', mime: 'audio/wav' };
  }
  return { buffer, ext: 'amr', mime: 'audio/amr' };
}

/**
 * 调用 OpenAI 兼容的语音识别接口 /v1/audio/transcriptions。
 * 模型取 cfg.transcribe_model(默认 whisper-1);多模型只取第一个。
 * @returns {Promise<string>} 识别出的文本
 */
async function callTranscribe(cfg, audio, meta) {
  const list = cfg.models && cfg.models.length ? cfg.models : [cfg];
  const m = list[0];
  if (!m || !m.api_base || !m.api_key) throw new Error('AI 未配置');
  const transModel = (m.transcribe_model) || cfg.transcribe_model || 'whisper-1';
  const base = normalizeBase(m.api_base);
  const url = base + '/v1/audio/transcriptions';

  const t0 = Date.now();
  const blob = new Blob([audio.buffer], { type: audio.mime || 'audio/wav' });
  const form = new FormData();
  form.append('file', blob, 'voice.' + (audio.ext || 'wav'));
  form.append('model', transModel);
  form.append('response_format', 'json');

  try {
    const resp = await axios.post(url, form, {
      headers: { Authorization: 'Bearer ' + m.api_key },
      timeout: 120000,
    });
    const text = resp.data?.text || resp.data?.transcript || '';
    const latency = Date.now() - t0;
    try {
      modelStats.logModelCall({
        bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
        peer_id: meta && meta.peer_id || null,
        model: transModel,
        mode: 'transcribe',
        success: 1,
        latency_ms: latency,
      });
    } catch (_) {}
    return String(text).trim();
  } catch (e) {
    try {
      modelStats.logModelCall({
        bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
        peer_id: meta && meta.peer_id || null,
        model: transModel,
        mode: 'transcribe',
        success: 0,
        error_msg: (e && e.message) || 'unknown',
        latency_ms: Date.now() - t0,
      });
    } catch (_) {}
    throw e;
  }
}

/**
 * 调用 MIMO 语音识别接口(api.xiaomimimo.com)。
 * 走 /v1/chat/completions,模型 mimo-v2.5-asr,音频以 input_audio(base64 data URL) 形式提交。
 * 详见:https://api.xiaomimimo.com/v1/chat/completions
 * @returns {Promise<string>} 识别出的文本
 */
async function callMimoTranscribe(cfg, audio, meta) {
  const apiKey = cfg.stt_mimo_api_key || '';
  const base = (cfg.stt_mimo_base || 'https://api.xiaomimimo.com').replace(/\/+$/, '');
  const model = cfg.stt_mimo_model || 'mimo-v2.5-asr';
  const lang = cfg.stt_mimo_lang || 'zh';
  if (!apiKey) throw new Error('MIMO API Key 未配置(后台「语音识别模式」选 MIMO 时需填写)');
  const b64 = audio.buffer.toString('base64');
  const mime = audio.mime || 'audio/wav';
  const url = base + '/v1/chat/completions';
  const t0 = Date.now();
  let resp;
  try {
    resp = await axios.post(url, {
      model,
      messages: [
        {
          role: 'user',
          content: [
            { type: 'input_audio', input_audio: { data: `data:${mime};base64,${b64}` } },
          ],
        },
      ],
      asr_options: { language: lang },
    }, { headers: { 'api-key': apiKey, 'Content-Type': 'application/json' }, timeout: 60000 });
  } catch (e) {
    const detail = e.response && e.response.data ? JSON.stringify(e.response.data) : e.message;
    try {
      modelStats.logModelCall({
        bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
        peer_id: meta && meta.peer_id || null,
        model,
        mode: 'transcribe',
        success: 0,
        error_msg: 'MIMO 请求失败:' + detail,
        latency_ms: Date.now() - t0,
      });
    } catch (_) {}
    throw new Error('MIMO 请求失败:' + detail);
  }
  const content = (resp && resp.data && resp.data.choices && resp.data.choices[0] && resp.data.choices[0].message && resp.data.choices[0].message.content)
    || (resp && resp.data && resp.data.choices && resp.data.choices[0] && resp.data.choices[0].text)
    || (resp && resp.data && resp.data.text)
    || '';
  const latency = Date.now() - t0;
  try {
    modelStats.logModelCall({
      bot_id: meta && meta.bot_id != null ? meta.bot_id : null,
      peer_id: meta && meta.peer_id || null,
      model,
      mode: 'transcribe',
      success: 1,
      latency_ms: latency,
    });
  } catch (_) {}
  return String(content).trim();
}

/**
 * 处理入站语音消息:下载解密 → SILK 解码 → 语音识别 → 像普通文本一样交给 AI 理解并回复。
 * @returns {Promise<boolean>} 是否已处理
 */
async function handleVoiceMessage(msg, ctx) {
  const botId = ctx.bot.id;
  const userId = ctx.bot.user_id;
  const peerId = msg.peer_id;

  const cfg = await resolveSmartConfig(botId);
  if (!cfgHasAI(cfg)) return false; // AI 未配置,放行给其他插件

  const tokenInfo = await getUserTokenInfo(userId);
  if (tokenInfo.used >= tokenInfo.limit) return false; // 额度用尽,放行

  // 下载并解密用户发来的语音(复用 ima-knowledge 的 CDN 下载 + AES 解密)
  let media;
  try {
    media = await imaPlugin.downloadInboundMedia(ctx.bot, msg.content);
  } catch (e) {
    console.error('[smart] 语音下载失败:', e.message);
    return false;
  }
  if (!media || !media.buffer || !media.buffer.length) return false;

  // 按「语音识别模式」分流:local=本地 Whisper(纯本地、无外部接口);ai=OpenAI 兼容接口
  const sttMode = (cfg.stt_mode || 'ai');
  let text;
  if (sttMode === 'local') {
    // —— 本地 Whisper 转写 ——
    let float32;
    try {
      float32 = await stt.decodeVoiceToFloat(media.buffer, media.ref);
    } catch (e) {
      console.error('[smart] 语音解码失败:', e.message);
      await ctx.sendText('[WARN] 语音解码失败:' + e.message);
      return true;
    }
    try {
      text = await stt.transcribeLocal(float32, { model: cfg.stt_model, language: 'chinese' });
    } catch (e) {
      console.error('[smart] 本地语音识别失败:', e.message);
      await ctx.sendText('[WARN] 本地语音识别失败:' + e.message +
        '\n(首次使用会自动下载模型,请确认服务器能访问 HuggingFace;国内可设置环境变量 HF_ENDPOINT=https://hf-mirror.com。' +
        '或后台把「语音识别模式」改回 AI 接口)');
      return true;
    }
  } else if (sttMode === 'ai') {
    // —— AI 接口(OpenAI 兼容 /v1/audio/transcriptions)——
    let audio;
    try {
      audio = await voiceToWav(media.buffer, media.ref);
    } catch (e) {
      console.error('[smart] 语音解码失败:', e.message);
      await ctx.sendText('[WARN] 语音解码失败:' + e.message);
      return true;
    }
    try {
      text = await callTranscribe(cfg, audio, { bot_id: botId, peer_id: peerId });
    } catch (e) {
      console.error('[smart] 语音识别失败:', e.message);
      await ctx.sendText('[WARN] 语音识别失败:' + e.message + '\n(请确认 AI 服务商支持 /v1/audio/transcriptions 语音识别,或在后台设置「语音识别模型」)');
      return true;
    }
  } else if (sttMode === 'mimo') {
    // —— MIMO 语音识别(api.xiaomimimo.com /v1/chat/completions,mimo-v2.5-asr)——
    let audio;
    try {
      audio = await voiceToWav(media.buffer, media.ref);
    } catch (e) {
      console.error('[smart] 语音解码失败:', e.message);
      await ctx.sendText('[WARN] 语音解码失败:' + e.message);
      return true;
    }
    try {
      text = await callMimoTranscribe(cfg, audio, { bot_id: botId, peer_id: peerId });
    } catch (e) {
      console.error('[smart] MIMO 语音识别失败:', e.message);
      await ctx.sendText('[WARN] MIMO 语音识别失败:' + e.message + '\n(请确认后台已填写 MIMO API Key,且服务商支持语音识别)');
      return true;
    }
  }
  if (!text) {
    await ctx.sendText('[WARN] 未识别到语音内容,请重试。');
    return true;
  }
  console.log('[smart] 语音识别结果:', JSON.stringify(text));

  // 回写识别结果到入库的语音消息:仅填充 t 字段(保留媒体引用 r),
  // 使网页聊天界面展示「语音转换后的文字」而非原始 JSON。
  if (msg.message_id && text) {
    try {
      let parsed;
      try { parsed = JSON.parse(msg.content); } catch (_) { parsed = null; }
      if (parsed && typeof parsed === 'object') {
        parsed.t = text;
        const newContent = JSON.stringify(parsed);
        await db.exec('UPDATE messages SET content=? WHERE id=?', [newContent, msg.message_id]);
      }
    } catch (e) {
      console.error('[smart] 语音消息回写失败:', e.message);
    }
  }

  // 把识别到的文字直接交给智能助手(像用户自己打字一样理解并回复),不额外回显提示
  return await processText(botId, peerId, userId, text, ctx, msg, true);
}


// ==================== 插件入口 ====================
/**
 * 供第三方插件直接调用智能助手(AI 文本生成 / function calling)。
 * 无需指令前缀、无需走聊天管线,插件可在自己的业务逻辑里随时调用 AI。
 *
 * @param {object} opts
 *   botId:number            机器人 ID(必填,决定用哪种模型配置)
 *   userId?:number          用户 ID(用于 Token 额度扣减;不传则不扣额度)
 *   prompt:string           要发给 AI 的内容(必填)
 *   systemPrompt?:string    覆盖默认系统提示词
 *   maxTokens?:number       覆盖 max_tokens
 *   temperature?:number     覆盖 temperature
 *   history?:[{role,content}]  多轮上下文(可选,置于 prompt 之前)
 *   tools?:[...]            OpenAI function 工具定义(可选)
 *   toolHandler?:async (name, args) => string   配合 tools 的工具执行器(可选)
 * @returns {Promise<string>} AI 回复文本
 *
 * 示例:
 *   const smart = require('../../plugins/smart');
 *   const reply = await smart.generate({ botId, userId, prompt: '用一句话解释量子纠缠' });
 */
async function generate(opts) {
  const { botId, userId, prompt } = opts || {};
  if (!botId) throw new Error('generate 缺少 botId');
  if (!prompt || !String(prompt).trim()) throw new Error('generate 缺少 prompt');

  const cfg = await resolveSmartConfig(botId);
  if (!cfgHasAI(cfg)) throw new Error('智能助手 AI 未配置');

  // Token 额度检查(与内置对话一致)
  if (userId) {
    const tokenInfo = await getUserTokenInfo(userId);
    if (tokenInfo.used >= tokenInfo.limit) throw new Error('AI Token 额度已用尽');
  }

  const systemPrompt = (opts.systemPrompt || cfg.system_prompt || DEFAULT_SYSTEM_PROMPT)
    + getPersonaText(cfg.persona || '')
    + await collectPluginPrompts(botId, opts && opts.peerId);
  const messages = [
    { role: 'system', content: systemPrompt },
    ...(opts.history && Array.isArray(opts.history) ? opts.history : []),
    { role: 'user', content: String(prompt) },
  ];

  let usageTokens = 0;
  let resp;
  try {
    if (opts.tools && opts.tools.length) {
      resp = await callAIWithFallback(cfg, messages, opts.tools, { bot_id: botId, peer_id: opts.peerId });
      let loops = 0;
      while (resp.choices?.[0]?.message?.tool_calls?.length > 0 && loops < MAX_TOOL_LOOPS) {
        loops++;
        const tcs = resp.choices[0].message.tool_calls;
        messages.push({ role: 'assistant', content: resp.choices[0].message.content || null, tool_calls: tcs });
        for (const tc of tcs) {
          const fn = tc.function?.name;
          let args = {};
          try { args = JSON.parse(tc.function?.arguments || '{}'); } catch (_) {}
          let result = '';
          if (typeof opts.toolHandler === 'function') {
            try { result = String(await opts.toolHandler(fn, args) ?? ''); }
            catch (e) { result = '工具执行失败: ' + e.message; }
          }
          messages.push({ role: 'tool', tool_call_id: tc.id, content: result });
        }
        resp = await callAIWithFallback(cfg, messages, null);
        usageTokens += resp.usage?.total_tokens || 0;
      }
    } else {
      resp = await callAIWithFallback(cfg, messages, null, { bot_id: botId, peer_id: opts.peerId });
    }
    usageTokens += resp.usage?.total_tokens || 0;
  } catch (e) {
    throw new Error('AI 调用失败: ' + e.message);
  }

  // 扣减 Token
  if (userId && usageTokens > 0) await addUserTokens(userId, usageTokens);
  return resp.choices?.[0]?.message?.content || '';
}

/** 返回当前机器人的模型模式(供前端/调试用) */
async function getAiMode(botId) {
  const cfg = await resolveSmartConfig(botId);
  return {
    source: cfg.source, // 'owner' | 'custom'
    customModels: cfg.models ? cfg.models.length : 0,
  };
}

module.exports = {
  meta: {
    id: 'smart',
    name: '智能助手',
    version: '1.3.0',
    author: '奶狗',
    category: 'AI对话',
    description: '自然语言智能助手,无需指令前缀。AI 自动理解意图,支持图片识别(认花/认物/识文字)、微信语音自动转文字并回复、知识库搜索、定时提醒、新闻简报、随机图片。图片默认只识别不入库,用户明确说"存到知识库"才保存;语音消息会自动识别成文字后由 AI 理解回复。AI 接口在「AI 配置」菜单统一设置。第三方插件也可直接调用本助手进行 AI 生成。',
    entry: 'smart/index.js',
    builtin: true,
  },

  // 供第三方插件调用
  generate,
  getAiMode,
  generateImage: callImageGeneration,
  handleGenerateImageTool,


  async onMessage(msg, ctx) {
    // 确保 reminders 表存在(只建一次,移出消息热路径)
    await ensureRemindersTable();
    // 启动提醒调度器(幂等)
    startReminderScheduler();

    const botId = ctx.bot.id;
    const userId = ctx.bot.user_id;
    const peerId = msg.peer_id;
    const text = (msg.content || '').trim();
    // 图片消息:用视觉模型自动识别内容(认花/物体/文字等),不自动存知识库
    if (msg.msg_type === 'image') {
      return await handleImageMessage(msg, ctx);
    }
    // 语音消息:下载解密 → SILK 解码 → 语音识别 → 像文本一样交给 AI 回复
    if (msg.msg_type === 'voice') {
      return await handleVoiceMessage(msg, ctx);
    }
    if (!text) return false;
    return await processText(botId, peerId, userId, text, ctx, msg, false);
  },
};

/**
 * 统一的文本处理管线:噪声过滤 → 指令让位 → 加载模型 → 对话历史 → function calling → 回复。
 * 普通文本消息与「语音识别后的文本」都走这里,保证语音消息也能被 AI 正常理解并回复。
 * @param {boolean} isVoice 是否来自语音识别(用于后续扩展,如跳过噪声过滤等)
 * @returns {Promise<boolean>} 是否已处理
 */
async function processText(botId, peerId, userId, text, ctx, msg, isVoice) {
  if (!text) return false;
  console.log('[smart] processText 进入: isVoice=' + isVoice + ', text=' + JSON.stringify(text.slice(0, 80)));

  // 过滤无意义噪声消息(随机字母、乱码等),节省 Token
  if (isNoiseMessage(text)) { console.log('[smart] 跳过:噪声消息'); return false; }

  // 让位给「指令型插件」:若消息以某插件的命令前缀开头,则不走 AI,交给对应插件处理
  // (例如 push 插件的「推送 绑定」等指令,不应被智能助手当成闲聊回复)
  try {
    const pluginsLib = require('../../lib/plugins');
    const others = (await pluginsLib.getEnabledModules(botId)).filter(m => m.meta && m.meta.id !== 'smart');
    const lower = text.toLowerCase();
    for (const m of others) {
      const prefixes = m.meta.commandPrefix;
      if (prefixes) {
        const arr = Array.isArray(prefixes) ? prefixes : [prefixes];
        if (arr.some(p => p && lower.startsWith(String(p).toLowerCase()))) {
          console.log('[smart] 跳过:命中插件指令前缀', m.meta.id, JSON.stringify(p));
          return false;
        }
      }
      // 柔性匹配:插件可声明 commandMatch 正则,命中则同样让位给该插件
      // (用于处理自然语言指令,如「看一下航天基地密码」)
      if (m.meta.commandMatch) {
        try {
          if (new RegExp(m.meta.commandMatch, 'i').test(text)) {
            console.log('[smart] 跳过:命中插件命令正则', m.meta.id, m.meta.commandMatch);
            return false;
          }
        } catch (_) { /* 正则无效则忽略 */ }
      }
    }
  } catch (_) { /* 忽略,继续走 AI */ }

  // 兑换卡密指令 → 交给 token-card 插件处理,不走 AI
  if (/^兑换\s+[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/i.test(text)) return false;

  // 加载 AI 接口配置(自定义模型或多模型轮询;否则使用站长模型)
  const cfg = await resolveSmartConfig(botId);
  if (!cfgHasAI(cfg)) {
    // AI 未配置,不接管消息,让其他插件处理
    console.log('[smart] 跳过:AI 未配置');
    return false;
  }

  // 检查 Token 额度
  const tokenInfo = await getUserTokenInfo(userId);
  if (tokenInfo.used >= tokenInfo.limit) {
    // Token 用完了,不走 AI,让其他插件处理
    console.log('[smart] 跳过:Token 额度用尽', JSON.stringify(tokenInfo));
    return false;
  }

  // 先发「请稍候」:在确定要走 AI 后、构建系统提示与收集插件工具之前就告知用户,
  // 避免配置文件/插件 prompt/tool 收集(可能遍历多个插件)的耗时让用户长时间无反馈。
  try { await ctx.sendText('⏳ 请稍候…'); }
  catch (_) { /* 发送失败不影响主流程 */ }

  // 添加用户消息到对话历史
  addConvMessage(botId, peerId, 'user', text);

  // 构建消息列表
  const history = getConvHistory(botId, peerId);
  const systemPrompt = (cfg.system_prompt || DEFAULT_SYSTEM_PROMPT)
    + getPersonaText(cfg.persona || '')
    + await collectPluginPrompts(botId, peerId);
  const messages = [
    { role: 'system', content: systemPrompt },
    ...history,
  ];

  // 收集第三方插件声明的 AI 工具,与内置工具合并(通用互通协议)
  const { tools: pluginTools, handlers: pluginHandlers } = await collectPluginTools(botId);
  const allTools = pluginTools.length ? [...TOOLS, ...pluginTools] : TOOLS;

  try {
    console.log('[smart] 开始调用 AI(tools=' + allTools.length + ')');
    let resp = await callAIWithFallback(cfg, messages, allTools, { bot_id: botId, peer_id: peerId });
    console.log('[smart] AI 返回成功,正文长度=' + (resp.choices?.[0]?.message?.content || '').length);
    let usageTokens = resp.usage?.total_tokens || 0;
    let loops = 0;
    const toolResults = [];

    // Function calling 循环
    while (resp.choices?.[0]?.message?.tool_calls?.length > 0 && loops < MAX_TOOL_LOOPS) {
      loops++;
      const toolCalls = resp.choices[0].message.tool_calls;

      // 添加 assistant 消息(含 tool_calls)
      messages.push({
        role: 'assistant',
        content: resp.choices[0].message.content || null,
        tool_calls: toolCalls,
      });

      // 执行每个工具
      for (const tc of toolCalls) {
        const fnName = tc.function?.name;
        let fnArgs = {};
        try { fnArgs = JSON.parse(tc.function?.arguments || '{}'); } catch (e) { /* ignore */ }

        console.log('[smart] 工具调用:', fnName, JSON.stringify(fnArgs).slice(0, 200));

        // 单工具失败不应中断整体流程:捕获后把错误作为工具结果回填
        try {
          const result = await executeTool(fnName, fnArgs, ctx, pluginHandlers);
          toolResults.push(String(result || ''));
          messages.push({
            role: 'tool',
            tool_call_id: tc.id,
            content: String(result),
          });
        } catch (e) {
          const errMsg = '工具执行失败: ' + e.message;
          console.error('[smart] 工具执行异常 (' + fnName + '):', e.message);
          toolResults.push(errMsg);
          messages.push({
            role: 'tool',
            tool_call_id: tc.id,
            content: errMsg,
          });
        }
      }

      // 继续对话
      resp = await callAIWithFallback(cfg, messages, null, { bot_id: botId, peer_id: peerId });
      usageTokens += resp.usage?.total_tokens || 0;
    }

    // 累计 Token
    if (usageTokens > 0) {
      await addUserTokens(userId, usageTokens);
    }

    // 获取最终回复
    let reply = resp.choices?.[0]?.message?.content;

    // 兜底:若最终只有 tool_calls 而无正文(部分模型会这样)
    if (!reply || !reply.trim()) {
      // 优先使用本轮工具执行结果:任务已执行(如已创建提醒/已发邮件),至少把结果回执给用户
      const toolText = toolResults.filter(Boolean).join('\n').trim();
      if (toolText) {
        console.log('[smart] 末轮无正文,改用工具执行结果回复');
        reply = toolText;
      } else {
        console.log('[smart] 末轮无正文,触发无工具兜底重试');
        messages.push({ role: 'user', content: '请直接用一段文字回答上面的请求,不要调用任何工具。' });
        try {
          const r2 = await callAIWithFallback(cfg, messages, null, { bot_id: botId, peer_id: peerId });
          reply = r2.choices?.[0]?.message?.content;
          usageTokens += r2.usage?.total_tokens || 0;
        } catch (e) {
          console.error('[smart] 兜底重试失败:', e.message);
        }
      }
    }

    if (reply && reply.trim()) {
      // 注意:会话历史里只存纯净正文,避免「内容由 AI 生成」反复污染上下文;
      // 「内容由 AI 生成」标注仅作为页脚附加在实际发送的消息末尾。
      addConvMessage(botId, peerId, 'assistant', reply.trim());

      console.log('[smart] 准备发送回复:', reply.trim().slice(0, 60));
      try {
        await ctx.sendText(reply.trim() + '\n\n— 内容由 AI 生成');
        console.log('[smart] 回复已发送');
        msgEvents.push(botId, 'outbound_ok', 'AI 回复已发送', reply.trim().slice(0, 80));
      } catch (e) {
        console.error('[smart] 回复发送失败:', e.message);
        msgEvents.push(botId, 'outbound_fail', 'AI 回复发送失败', e.message);
        throw e;
      }
      return true;
    }

    // 无有效回复
    console.log('[smart] AI 返回空回复(content 为空),未发送。tool_calls=', JSON.stringify(resp.choices?.[0]?.message?.tool_calls?.map(t => t.function?.name)));
    msgEvents.push(botId, 'outbound_fail', 'AI 未返回可发送内容', '工具结果: ' + toolResults.filter(Boolean).join(' | ').slice(0, 120));
    return false;
  } catch (err) {
    console.error('[smart] AI调用失败:', err.message);
    // 向上抛出,由 plugins.onMessage 记录 error 事件并让其他插件尝试处理
    throw err;
  }
}