From 4f6e86249d1da828f5a07632c6c5c273af7357ef Mon Sep 17 00:00:00 2001 From: brsbsc Date: Wed, 21 Jan 2026 12:36:40 +0800 Subject: [PATCH 1/5] feat(ai): add timeout reset command and cleanup method --- ai/ai.ts | 2638 ++++++++++++++++++++++++------------------------------ 1 file changed, 1162 insertions(+), 1476 deletions(-) diff --git a/ai/ai.ts b/ai/ai.ts index 99e26cbd..38d93f44 100644 --- a/ai/ai.ts +++ b/ai/ai.ts @@ -1,16 +1,18 @@ /** * TeleBox AI 插件(完美整合版) - * 兼容 OpenAI / Gemini / Claude / 百度 等标准接口 + * 兼容 OpenAI / Gemini / Claude / 火山 等标准接口 * 功能:对话、搜索、识图、生图、TTS、语音回答、全局 Prompt 预设、上下文记忆、 Telegraph 长文等 * 用法:.ai 或 .ai chat|search|image|tts|audio|searchaudio|prompt|config|model|... * 2025-05 最终优化版 */ import { Plugin } from "@utils/pluginBase"; +import { getPrefixes } from "@utils/pluginManager"; import { Api } from "telegram"; import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; import { JSONFilePreset } from "lowdb/node"; import * as path from "path"; import * as fs from "fs"; +import sharp from "sharp"; import { createDirectoryInAssets } from "@utils/pathHelpers"; /* ---------- 类型定义 ---------- */ @@ -43,62 +45,39 @@ type DB = { histories: Record; histMeta?: Record; presetPrompt?: string; // 全局 Prompt 预设 + timeout?: number; // 全局超时时间(毫秒) + maxTokens?: number; // 最大输出 token 数 + linkPreview?: boolean; // 链接即时预览开关 + }; /* ---------- 常量 ---------- */ const MAX_MSG = 4096; const PAGE_EXTRA = 48; const WRAP_EXTRA_COLLAPSED = 64; +const HISTORY_MAX_ITEMS = 50; +const HISTORY_MAX_BYTES = 64 * 1024; +const MODEL_REFRESH_DEBOUNCE_MS = 2000; +const DEFAULT_TIMEOUT_MS = 30000; // 默认超时 30 秒 +const MAX_TIMEOUT_MS = 600000; // 最大超时 10 分钟 +const DEFAULT_MAX_TOKENS = 16384; // 默认最大输出 token(约8000中文字) const GEMINI_VOICES = [ - "Zephyr", - "Puck", - "Charon", - "Kore", - "Fenrir", - "Leda", - "Orus", - "Aoede", - "Callirhoe", - "Autonoe", - "Enceladus", - "Iapetus", - "Umbriel", - "Algieba", - "Despina", - "Erinome", - "Algenib", - "Rasalgethi", - "Laomedeia", - "Achernar", - "Alnilam", - "Schedar", - "Gacrux", - "Pulcherrima", - "Achird", - "Zubenelgenubi", - "Vindemiatrix", - "Sadachbia", - "Sadaltager", - "Sulafar", -] as const; -const OPENAI_VOICES = [ - "alloy", - "echo", - "fable", - "onyx", - "nova", - "shimmer", + "Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda", "Orus", "Aoede", + "Callirhoe", "Autonoe", "Enceladus", "Iapetus", "Umbriel", "Algieba", + "Despina", "Erinome", "Algenib", "Rasalgethi", "Laomedeia", "Achernar", + "Alnilam", "Schedar", "Gacrux", "Pulcherrima", "Achird", "Zubenelgenubi", + "Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafar" ] as const; +const OPENAI_VOICES = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"] as const; /* ---------- 工具函数 ---------- */ +// 动态获取命令前缀 +const prefixes = getPrefixes(); +const mainPrefix = prefixes[0]; + const trimBase = (u: string) => u.replace(/\/$/, ""); const html = (t: string) => - t - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + t.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const shortenUrlForDisplay = (u: string) => { try { @@ -113,19 +92,13 @@ const shortenUrlForDisplay = (u: string) => { } }; const nowISO = () => new Date().toISOString(); -const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); +const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); const shouldRetry = (err: any): boolean => { const s = err?.response?.status; const code = err?.code; return ( - s === 429 || - s === 500 || - s === 502 || - s === 503 || - s === 504 || - code === "ECONNRESET" || - code === "ETIMEDOUT" || - code === "ENOTFOUND" || + s === 429 || s === 500 || s === 502 || s === 503 || s === 504 || + code === "ECONNRESET" || code === "ETIMEDOUT" || code === "ENOTFOUND" || !!(err?.isAxiosError && !err?.response) ); }; @@ -136,13 +109,25 @@ const axiosWithRetry = async ( ): Promise> => { let attempt = 0; let lastErr: any; - const defaultTimeout = config.url?.includes("/messages") ? 90000 : 30000; - const baseConfig: AxiosRequestConfig = { timeout: defaultTimeout, ...config }; + const configuredTimeout = Store.data.timeout || DEFAULT_TIMEOUT_MS; while (attempt <= tries) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), configuredTimeout); try { - return await axios(baseConfig); + const baseConfig: AxiosRequestConfig = { + timeout: configuredTimeout, + signal: controller.signal, + ...config + }; + const result = await axios(baseConfig); + clearTimeout(timeoutId); + return result; } catch (err: any) { + clearTimeout(timeoutId); lastErr = err; + if (err.name === 'CanceledError' || err.code === 'ERR_CANCELED') { + throw new Error(`请求超时(${configuredTimeout / 1000}秒)`); + } if (attempt >= tries || !shouldRetry(err)) throw err; const jitter = Math.floor(Math.random() * 200); await sleep(backoffMs * Math.pow(2, attempt) + jitter); @@ -167,7 +152,7 @@ enum AuthMethod { API_KEY_HEADER = "api_key_header", QUERY_PARAM = "query_param", BASIC_AUTH = "basic_auth", - CUSTOM_HEADER = "custom_header", + CUSTOM_HEADER = "custom_header" } interface AuthConfig { method: AuthMethod; @@ -207,11 +192,7 @@ class UniversalAuthHandler { } static detectAuthMethod(baseUrl: string): AuthMethod { const url = baseUrl.toLowerCase(); - if ( - url.includes("generativelanguage.googleapis.com") || - url.includes("aiplatform.googleapis.com") || - url.endsWith("/google-ai-studio") - ) + if (url.includes("generativelanguage.googleapis.com") || url.includes("aiplatform.googleapis.com")) return AuthMethod.QUERY_PARAM; if (url.includes("anthropic.com")) return AuthMethod.API_KEY_HEADER; if (url.includes("aip.baidubce.com")) return AuthMethod.QUERY_PARAM; @@ -220,15 +201,9 @@ class UniversalAuthHandler { } /* ---------- 统一鉴权构建 ---------- */ -const buildAuthAttempts = ( - p: Provider, - extraHeaders: Record = {} -) => { +const buildAuthAttempts = (p: Provider, extraHeaders: Record = {}) => { if (p.authConfig) { - const headers = { - ...UniversalAuthHandler.buildAuthHeaders(p.authConfig), - ...extraHeaders, - }; + const headers = { ...UniversalAuthHandler.buildAuthHeaders(p.authConfig), ...extraHeaders }; const params = { ...UniversalAuthHandler.buildAuthParams(p.authConfig) }; return [{ headers, params }]; } @@ -236,31 +211,18 @@ const buildAuthAttempts = ( const cfg: AuthConfig = { method: detected, apiKey: p.apiKey, - headerName: - detected === AuthMethod.API_KEY_HEADER ? "x-api-key" : undefined, - paramName: detected === AuthMethod.QUERY_PARAM ? "key" : undefined, - }; - const headers = { - ...UniversalAuthHandler.buildAuthHeaders(cfg), - ...extraHeaders, + headerName: detected === AuthMethod.API_KEY_HEADER ? "x-api-key" : undefined, + paramName: detected === AuthMethod.QUERY_PARAM ? "key" : undefined }; + const headers = { ...UniversalAuthHandler.buildAuthHeaders(cfg), ...extraHeaders }; const params = { ...UniversalAuthHandler.buildAuthParams(cfg) }; return [{ headers, params }]; }; -const tryPostJSON = async ( - url: string, - body: any, - attempts: Array<{ headers?: any; params?: any }> -) => { +const tryPostJSON = async (url: string, body: any, attempts: Array<{ headers?: any; params?: any }>) => { let lastErr: any; for (const a of attempts) { try { - const r = await axiosWithRetry({ - method: "POST", - url, - data: body, - ...(a || {}), - }); + const r = await axiosWithRetry({ method: "POST", url, data: body, ...(a || {}) }); return r.data; } catch (err) { lastErr = err; @@ -281,6 +243,7 @@ class Store { voices: { gemini: "Kore", openai: "alloy" }, histories: {}, presetPrompt: "", + timeout: DEFAULT_TIMEOUT_MS }; static baseDir = ""; static file = ""; @@ -290,51 +253,28 @@ class Store { this.file = path.join(this.baseDir, "config.json"); this.db = await JSONFilePreset(this.file, this.data); this.data = this.db.data; - // 迁移逻辑 const d: any = this.data; - if (typeof d.dataVersion !== "number") d.dataVersion = 1; - if (!d.providers) d.providers = {}; - if (!d.modelCompat) d.modelCompat = {}; - if (!d.modelCatalog) d.modelCatalog = { map: {}, updatedAt: undefined }; - if (!d.models) d.models = { chat: "", search: "", image: "", tts: "" }; - if (typeof d.contextEnabled !== "boolean") d.contextEnabled = false; - if (typeof d.collapse !== "boolean") d.collapse = false; - if (!d.telegraph) - d.telegraph = { enabled: false, limit: 0, token: "", posts: [] }; - if (!d.voices) d.voices = { gemini: "Kore", openai: "alloy" }; - if (!d.histories) d.histories = {}; - if (!d.histMeta) d.histMeta = {}; - if (typeof d.presetPrompt !== "string") d.presetPrompt = ""; - if (d.dataVersion < 2) d.dataVersion = 2; - if (d.dataVersion < 3) { - try { - await refreshModelCatalog(true); - } catch {} - d.dataVersion = 3; - } - if (d.dataVersion < 4) { - if (!d.voices) d.voices = { gemini: "Kore", openai: "alloy" }; - d.dataVersion = 4; - } - if (d.dataVersion < 5) { - if (typeof d.presetPrompt !== "string") d.presetPrompt = ""; - d.dataVersion = 5; - } + // 默认值填充 + const defaults: Record = { + dataVersion: 5, providers: {}, modelCompat: {}, modelCatalog: { map: {}, updatedAt: undefined }, + models: { chat: "", search: "", image: "", tts: "" }, contextEnabled: false, collapse: false, + telegraph: { enabled: false, limit: 0, token: "", posts: [] }, voices: { gemini: "Kore", openai: "alloy" }, + histories: {}, histMeta: {}, presetPrompt: "", timeout: DEFAULT_TIMEOUT_MS, maxTokens: DEFAULT_MAX_TOKENS + }; + for (const [k, v] of Object.entries(defaults)) if (d[k] === undefined || d[k] === null) d[k] = v; + if (d.dataVersion < 3) { try { await refreshModelCatalog(true); } catch { } d.dataVersion = 5; } + // 确保超时值在有效范围内 + if (d.timeout > MAX_TIMEOUT_MS) d.timeout = MAX_TIMEOUT_MS; + if (d.timeout < 10000) d.timeout = DEFAULT_TIMEOUT_MS; await this.writeSoon(); } - static async write() { - await atomicWriteJSON(this.file, this.data); - } + static async write() { await atomicWriteJSON(this.file, this.data); } static writeSoonDelay = 300; static _writeTimer: NodeJS.Timeout | null = null; static async writeSoon(): Promise { if (this._writeTimer) clearTimeout(this._writeTimer); this._writeTimer = setTimeout(async () => { - try { - await atomicWriteJSON(this.file, this.data); - } finally { - this._writeTimer = null; - } + try { await atomicWriteJSON(this.file, this.data); } finally { this._writeTimer = null; } }, this.writeSoonDelay); return Promise.resolve(); } @@ -350,65 +290,56 @@ const buildChunks = (text: string, collapse?: boolean, postfix?: string) => { const WRAP_EXTRA = collapse ? WRAP_EXTRA_COLLAPSED : 0; const parts = splitMessage(text, PAGE_EXTRA + WRAP_EXTRA); if (parts.length === 0) return []; - if (parts.length === 1) - return [applyWrap(parts[0], collapse) + (postfix || "")]; + if (parts.length === 1) return [applyWrap(parts[0], collapse) + (postfix || "")]; const total = parts.length; const chunks: string[] = []; for (let i = 0; i < total; i++) { const isLast = i === total - 1; const header = `📄 (${i + 1}/${total})\n\n`; const body = header + parts[i]; - const wrapped = applyWrap(body, collapse) + (isLast ? postfix || "" : ""); + const wrapped = applyWrap(body, collapse) + (isLast ? (postfix || "") : ""); chunks.push(wrapped); } return chunks; }; -const sendLong = async ( - msg: Api.Message, - text: string, - opts?: { collapse?: boolean }, - postfix?: string -) => { +const sendLong = async (msg: Api.Message, text: string, opts?: { collapse?: boolean }, postfix?: string) => { const chunks = buildChunks(text, opts?.collapse, postfix); if (chunks.length === 0) return; - if (chunks.length === 1) { - await msg.edit({ text: chunks[0], parseMode: "html" }); - return; - } + if (chunks.length === 1) { await msg.edit({ text: chunks[0], parseMode: "html" }); return; } await msg.edit({ text: chunks[0], parseMode: "html" }); if (msg.client) { const peer = msg.peerId; - for (let i = 1; i < chunks.length; i++) - await msg.client.sendMessage(peer, { - message: chunks[i], - parseMode: "html", - }); + for (let i = 1; i < chunks.length; i++) await msg.client.sendMessage(peer, { message: chunks[i], parseMode: "html" }); } else { - for (let i = 1; i < chunks.length; i++) - await msg.reply({ message: chunks[i], parseMode: "html" }); + for (let i = 1; i < chunks.length; i++) await msg.reply({ message: chunks[i], parseMode: "html" }); } }; -const sendLongReply = async ( - msg: Api.Message, - replyToId: number, - text: string, - opts?: { collapse?: boolean }, - postfix?: string -) => { +const sendLongReply = async (msg: Api.Message, replyToId: number, text: string, opts?: { collapse?: boolean }, postfix?: string) => { const chunks = buildChunks(text, opts?.collapse, postfix); if (!msg.client) return; const peer = msg.peerId; - for (const chunk of chunks) - await msg.client.sendMessage(peer, { - message: chunk, - parseMode: "html", - replyTo: replyToId, - }); + for (const chunk of chunks) await msg.client.sendMessage(peer, { message: chunk, parseMode: "html", replyTo: replyToId }); }; const extractText = (m: Api.Message | null | undefined) => { if (!m) return ""; const anyM: any = m; - return anyM.message || anyM.text || anyM.caption || ""; + return (anyM.message || anyM.text || anyM.caption || ""); +}; +/** + * 提取引用文本或被回复消息的文本 + * 优先级:1. 引用文本 (quoteText) 2. 被回复消息的内容 + * @param msg 当前消息 + * @param replyMsg 被回复的消息(通过 getReplyMessage 获取) + * @returns 引用文本或被回复消息的文本 + */ +const extractQuoteOrReplyText = (msg: Api.Message, replyMsg: Api.Message | null | undefined): string => { + // 优先使用引用文本 (Telegram 的 quote reply 功能) + const quoteText = (msg.replyTo as any)?.quoteText; + if (quoteText && typeof quoteText === "string" && quoteText.trim()) { + return quoteText.trim(); + } + // 回退到被回复消息的内容 + return extractText(replyMsg); }; const splitMessage = (text: string, reserve = 0) => { const limit = Math.max(1, MAX_MSG - Math.max(0, reserve)); @@ -417,32 +348,19 @@ const splitMessage = (text: string, reserve = 0) => { let cur = ""; for (const line of text.split("\n")) { if (line.length > limit) { - if (cur) { - parts.push(cur); - cur = ""; - } - for (let i = 0; i < line.length; i += limit) - parts.push(line.slice(i, i + limit)); + if (cur) { parts.push(cur); cur = ""; } + for (let i = 0; i < line.length; i += limit) parts.push(line.slice(i, i + limit)); continue; } const next = cur ? cur + "\n" + line : line; - if (next.length > limit) { - parts.push(cur); - cur = line; - } else { - cur = next; - } + if (next.length > limit) { parts.push(cur); cur = line; } else { cur = next; } } if (cur) parts.push(cur); return parts; }; /* ---------- 兼容类型检测 ---------- */ -const detectCompat = ( - _name: string, - model: string, - _baseUrl: string -): Compat => { +const detectCompat = (model: string): Compat => { const m = (model || "").toLowerCase(); if (/\bclaude\b|anthropic/.test(m)) return "claude"; if (/\bgemini\b|(^gemini-)|image-generation/.test(m)) return "gemini"; @@ -451,10 +369,7 @@ const detectCompat = ( }; /* ---------- 模型目录 ---------- */ -const catalogInflight: { - refreshing: boolean; - lastPromise: Promise | null; -} = { refreshing: false, lastPromise: null }; +const catalogInflight: { refreshing: boolean; lastPromise: Promise | null } = { refreshing: false, lastPromise: null }; const getCompatFromCatalog = (model: string): Compat | null => { const ml = String(model || "").toLowerCase(); const map = Store.data.modelCatalog?.map || ({} as Record); @@ -462,8 +377,7 @@ const getCompatFromCatalog = (model: string): Compat | null => { return v ?? null; }; const refreshModelCatalog = async (force = false): Promise => { - if (!force && catalogInflight.refreshing) - return catalogInflight.lastPromise || Promise.resolve(); + if (!force && catalogInflight.refreshing) return catalogInflight.lastPromise || Promise.resolve(); catalogInflight.refreshing = true; const work = (async () => { try { @@ -472,15 +386,11 @@ const refreshModelCatalog = async (force = false): Promise => { for (const [, p] of entries) { try { const res = await listModelsByAnyCompat(p); - const mp: Record = ((res as any).modelMap || - {}) as Record; + const mp: Record = (((res as any).modelMap) || {}) as Record; for (const [k, v] of Object.entries(mp)) merged[k] = v; - } catch {} + } catch { } } - const catalog = (Store.data.modelCatalog ??= { - map: {}, - updatedAt: undefined, - } as any); + const catalog = (Store.data.modelCatalog ??= { map: {}, updatedAt: undefined } as any); (catalog as any).map = merged as any; (catalog as any).updatedAt = nowISO(); await Store.writeSoon(); @@ -492,56 +402,43 @@ const refreshModelCatalog = async (force = false): Promise => { catalogInflight.lastPromise = work; return work; }; +/* ---------- 模型刷新防抖 ---------- */ +let refreshDebounceTimer: NodeJS.Timeout | null = null; +const debouncedRefreshModelCatalog = () => { + if (refreshDebounceTimer) clearTimeout(refreshDebounceTimer); + refreshDebounceTimer = setTimeout(() => { + refreshDebounceTimer = null; + refreshModelCatalog(true).catch(() => { }); + }, MODEL_REFRESH_DEBOUNCE_MS); +}; const compatResolving = new Map>(); -const resolveCompat = async ( - name: string, - model: string, - p: Provider -): Promise => { +const resolveCompat = async (name: string, model: string, p: Provider): Promise => { const ml = String(model || "").toLowerCase(); const cat = getCompatFromCatalog(ml); if (cat) return cat; - const mc = - Store.data.modelCompat && (Store.data.modelCompat as any)[name] - ? ((Store.data.modelCompat as any)[name][ml] as Compat | undefined) - : undefined; - const byName = detectCompat(name, model, p.baseUrl); + const mc = (Store.data.modelCompat && (Store.data.modelCompat as any)[name]) ? (Store.data.modelCompat as any)[name][ml] as Compat | undefined : undefined; + const byName = detectCompat(model); if (mc) return mc; - setTimeout(() => { - void refreshModelCatalog(false).catch(() => {}); - }, 0); - const pending = - compatResolving.get(name + "::" + ml) || compatResolving.get(name); + setTimeout(() => { void refreshModelCatalog(false).catch(() => { }); }, 0); + const pending = compatResolving.get(name + "::" + ml) || compatResolving.get(name); if (pending) return await pending; const task = (async () => { try { const res = await listModelsByAnyCompat(p); const primary: Compat | null = (res.compat as Compat) || null; - const map: Record = ((res as any).modelMap || - {}) as Record; + const map: Record = (((res as any).modelMap) || {}) as Record; if (!Store.data.modelCompat) Store.data.modelCompat = {} as any; - if (!(Store.data.modelCompat as any)[name]) - (Store.data.modelCompat as any)[name] = {} as any; + if (!(Store.data.modelCompat as any)[name]) (Store.data.modelCompat as any)[name] = {} as any; for (const [k, v] of Object.entries(map)) { - const cur = (Store.data.modelCompat as any)[name][k] as - | Compat - | undefined; + const cur = (Store.data.modelCompat as any)[name][k] as Compat | undefined; if (cur !== v) (Store.data.modelCompat as any)[name][k] = v; } let comp: Compat = (Store.data.modelCompat as any)[name][ml] as Compat; if (!comp) comp = (primary as Compat) || byName; - if ( - ((Store.data.modelCompat as any)[name][ml] as Compat | undefined) !== - comp - ) - (Store.data.modelCompat as any)[name][ml] = comp; - const cat = (Store.data.modelCatalog ??= { - map: {}, - updatedAt: undefined, - } as any); + if (((Store.data.modelCompat as any)[name][ml] as Compat | undefined) !== comp) (Store.data.modelCompat as any)[name][ml] = comp; + const cat = (Store.data.modelCatalog ??= { map: {}, updatedAt: undefined } as any); const catMap = (cat as any).map as Record; - for (const [k, v] of Object.entries(map)) - if ((catMap as any)[k] !== v) (catMap as any)[k] = v as Compat; + for (const [k, v] of Object.entries(map)) if ((catMap as any)[k] !== v) (catMap as any)[k] = v as Compat; if ((catMap as any)[ml] !== comp) (catMap as any)[ml] = comp; (cat as any).updatedAt = nowISO(); if (primary && p) if (p.compatauth !== primary) p.compatauth = primary; @@ -550,16 +447,10 @@ const resolveCompat = async ( } catch { const comp: Compat = byName; if (!Store.data.modelCompat) Store.data.modelCompat = {} as any; - if (!(Store.data.modelCompat as any)[name]) - (Store.data.modelCompat as any)[name] = {} as any; - if (!(Store.data.modelCompat as any)[name][ml]) - (Store.data.modelCompat as any)[name][ml] = comp; - try { - await Store.writeSoon(); - } catch {} - setTimeout(() => { - void refreshModelCatalog(false).catch(() => {}); - }, 0); + if (!(Store.data.modelCompat as any)[name]) (Store.data.modelCompat as any)[name] = {} as any; + if (!(Store.data.modelCompat as any)[name][ml]) (Store.data.modelCompat as any)[name][ml] = comp; + try { await Store.writeSoon(); } catch { } + setTimeout(() => { void refreshModelCatalog(false).catch(() => { }); }, 0); return comp; } finally { compatResolving.delete(name + "::" + ml); @@ -574,15 +465,13 @@ const resolveCompat = async ( const mapError = (err: any, ctx?: string): string => { const s = err?.response?.status as number | undefined; const body = err?.response?.data; - const raw = - body?.error?.message || body?.message || err?.message || String(err); + const raw = body?.error?.message || body?.message || err?.message || String(err); let hint = ""; - if (s === 401 || s === 403) - hint = "认证失败,请检查 API Key 是否正确、是否有对应权限"; + if (s === 400) hint = "请求格式有误,可能是模型不支持当前参数或输入过长"; + else if (s === 401 || s === 403) hint = "认证失败,请检查 API Key 是否正确、是否有对应权限"; else if (s === 404) hint = "接口不存在,请检查 BaseURL/兼容类型或服务商路由"; else if (s === 429) hint = "请求过于频繁或额度受限,请稍后重试或调整速率"; - else if (typeof s === "number" && s >= 500) - hint = "服务端异常,请稍后重试或更换服务商"; + else if (typeof s === "number" && s >= 500) hint = "服务端异常,请稍后重试或更换服务商"; else if (!s) hint = "网络异常,请检查网络或 BaseURL"; const where = ctx ? `(${ctx})` : ""; return `${raw}${hint ? "|" + hint : ""}${s ? `|HTTP ${s}` : ""}${where}`; @@ -601,9 +490,7 @@ const normalizeModelName = (x: any): string => { }; /* ---------- 快捷选取 ---------- */ -const pick = ( - kind: keyof Models -): { provider: string; model: string } | null => { +const pick = (kind: keyof Models): { provider: string; model: string } | null => { const s = Store.data.models[kind]; if (!s) return null; const i = s.indexOf(" "); @@ -612,59 +499,43 @@ const pick = ( const model = s.slice(i + 1); return { provider, model }; }; -const providerOf = (name: string): Provider | null => - Store.data.providers[name] || null; +const providerOf = (name: string): Provider | null => Store.data.providers[name] || null; const footer = (model: string, extra?: string) => { - const src = model.toLowerCase().includes("claude") - ? "Anthropic Claude" - : model.toLowerCase().includes("gemini") - ? "Google Gemini" - : "OpenAI"; + const src = model.toLowerCase().includes("claude") ? "Anthropic Claude" : model.toLowerCase().includes("gemini") ? "Google Gemini" : "OpenAI"; return `\n\nPowered by ${src}${extra ? " " + extra : ""}`; }; const ensureDir = () => { - if (!fs.existsSync(Store.baseDir)) - fs.mkdirSync(Store.baseDir, { recursive: true }); -}; -const chatIdStr = (msg: Api.Message) => - String( - (msg.peerId as any)?.channelId || - (msg.peerId as any)?.userId || - (msg.peerId as any)?.chatId || - "global" - ); + if (!fs.existsSync(Store.baseDir)) fs.mkdirSync(Store.baseDir, { recursive: true }); +}; +/* ---------- 上下文隔离(用户+会话) ---------- */ +const contextKey = (msg: Api.Message): string => { + const chatId = String((msg.peerId as any)?.channelId || (msg.peerId as any)?.userId || (msg.peerId as any)?.chatId || "global"); + const userId = String((msg as any).senderId || (msg as any).fromId?.userId || "unknown"); + return `${userId}:${chatId}`; +}; +const chatIdStr = (msg: Api.Message) => contextKey(msg); // 兼容别名 +const isGroupOrChannel = (msg: Api.Message): boolean => { + const peer = msg.peerId; + return (peer as any)?.className === "PeerChannel" || (peer as any)?.className === "PeerChat"; +}; const histFor = (id: string) => Store.data.histories[id] || []; const HISTORY_GLOBAL_MAX_SESSIONS = 200; const HISTORY_GLOBAL_MAX_BYTES = 2 * 1024 * 1024; const pruneGlobalHistories = () => { const ids = Object.keys(Store.data.histories || {}); if (!ids.length) return; - const meta = (Store.data.histMeta || {}) as Record< - string, - { lastAt: string } - >; - const sizeOfItem = (x: { role: string; content: string }) => - Buffer.byteLength(`${x.role}:${x.content}`); - const sizeOfHist = (arr: { role: string; content: string }[]) => - arr.reduce((t, x) => t + sizeOfItem(x), 0); + const meta = (Store.data.histMeta || {}) as Record; + const sizeOfItem = (x: { role: string; content: string }) => Buffer.byteLength(`${x.role}:${x.content}`); + const sizeOfHist = (arr: { role: string; content: string }[]) => arr.reduce((t, x) => t + sizeOfItem(x), 0); let totalBytes = 0; - for (const id of ids) - totalBytes += sizeOfHist(Store.data.histories[id] || []); - if ( - ids.length <= HISTORY_GLOBAL_MAX_SESSIONS && - totalBytes <= HISTORY_GLOBAL_MAX_BYTES - ) - return; + for (const id of ids) totalBytes += sizeOfHist(Store.data.histories[id] || []); + if (ids.length <= HISTORY_GLOBAL_MAX_SESSIONS && totalBytes <= HISTORY_GLOBAL_MAX_BYTES) return; const sorted = ids.sort((a, b) => { - const ta = Date.parse(meta[a]?.lastAt || "1970-01-01T00:00:00.000Z"); - const tb = Date.parse(meta[b]?.lastAt || "1970-01-01T00:00:00.000Z"); + const ta = Date.parse((meta[a]?.lastAt) || "1970-01-01T00:00:00.000Z"); + const tb = Date.parse((meta[b]?.lastAt) || "1970-01-01T00:00:00.000Z"); return ta - tb; }); - while ( - (sorted.length > HISTORY_GLOBAL_MAX_SESSIONS || - totalBytes > HISTORY_GLOBAL_MAX_BYTES) && - sorted.length - ) { + while ((sorted.length > HISTORY_GLOBAL_MAX_SESSIONS || totalBytes > HISTORY_GLOBAL_MAX_BYTES) && sorted.length) { const victim = sorted.shift()!; const arr = Store.data.histories[victim] || []; totalBytes -= sizeOfHist(arr); @@ -676,17 +547,11 @@ const pushHist = (id: string, role: string, content: string) => { if (!Store.data.histories[id]) Store.data.histories[id] = []; Store.data.histories[id].push({ role, content }); const h = Store.data.histories[id]; - const MAX_ITEMS = 50; - while (h.length > MAX_ITEMS) h.shift(); - const MAX_BYTES = 64 * 1024; - const sizeOf = (x: { role: string; content: string }) => - Buffer.byteLength(`${x.role}:${x.content}`); + while (h.length > HISTORY_MAX_ITEMS) h.shift(); + const sizeOf = (x: { role: string; content: string }) => Buffer.byteLength(`${x.role}:${x.content}`); let total = 0; for (const x of h) total += sizeOf(x); - while (total > MAX_BYTES && h.length > 1) { - const first = h.shift()!; - total -= sizeOf(first); - } + while (total > HISTORY_MAX_BYTES && h.length > 1) { const first = h.shift()!; total -= sizeOf(first); } if (!Store.data.histMeta) Store.data.histMeta = {} as any; (Store.data.histMeta as any)[id] = { lastAt: new Date().toISOString() }; pruneGlobalHistories(); @@ -706,23 +571,17 @@ const escapeAndFormatForTelegram = (raw: string): string => { let escaped = html(cleaned); escaped = escaped.replace(/\*\*([^*]+)\*\*/g, "$1"); escaped = escaped.replace(/\*\*\s*\[?引用来源]?\s*\*\*/g, "引用来源"); - escaped = escaped.replace( - /^\s*-\s*\[([^]]+)]\((https?:\/\/[^\s)]+)\)\s*$/gm, - (_m, title: string, url: string) => { - const href = html(String(url)); - return `• ${title}`; - } - ); + escaped = escaped.replace(/^\s*-\s*\[([^]]+)]\((https?:\/\/[^\s)]+)\)\s*$/gm, (_m, title: string, url: string) => { + const href = html(String(url)); + return `• ${title}`; + }); const urlRegex = /\bhttps?:\/\/[^\s<>"')}\x5D]+/g; const urls = cleaned.match(urlRegex) || []; for (const u of urls) { const display = shortenUrlForDisplay(u); const escapedUrl = html(u); const anchor = `${html(display)}`; - escaped = escaped.replace( - new RegExp(escapeRegExp(escapedUrl), "g"), - anchor - ); + escaped = escaped.replace(new RegExp(escapeRegExp(escapedUrl), "g"), anchor); } escaped = escaped.replace(/^>\s?(.+)$/gm, "
$1
"); return escaped; @@ -732,50 +591,24 @@ const escapeAndFormatForTelegram = (raw: string): string => { const isRouteError = (err: any): boolean => { const s = err?.response?.status; const txt = String(err?.response?.data || err?.message || "").toLowerCase(); - return ( - s === 404 || - s === 405 || - (s === 400 && /(unknown|not found|invalid path|no route)/.test(txt)) - ); + return s === 404 || s === 405 || (s === 400 && /(unknown|not found|invalid path|no route)/.test(txt)); }; -const geminiRequestWithFallback = async ( - p: Provider, - path: string, - axiosConfig: any -): Promise => { +const geminiRequestWithFallback = async (p: Provider, path: string, axiosConfig: any): Promise => { const base = trimBase(p.baseUrl); const mkConfigs = () => { const baseCfg = { ...axiosConfig }; const headersBase = { ...(baseCfg.headers || {}) }; const paramsBase = { ...(baseCfg.params || {}) }; - const cfgKey = { - ...baseCfg, - headers: { ...headersBase }, - params: { ...paramsBase, key: p.apiKey }, - }; - const cfgXGoog = { - ...baseCfg, - headers: { ...headersBase, "x-goog-api-key": p.apiKey }, - params: { ...paramsBase }, - }; - const cfgAuth = { - ...baseCfg, - headers: { ...headersBase, Authorization: `Bearer ${p.apiKey}` }, - params: { ...paramsBase }, - }; + const cfgKey = { ...baseCfg, headers: { ...headersBase }, params: { ...paramsBase, key: p.apiKey } }; + const cfgXGoog = { ...baseCfg, headers: { ...headersBase, "x-goog-api-key": p.apiKey }, params: { ...paramsBase } }; + const cfgAuth = { ...baseCfg, headers: { ...headersBase, Authorization: `Bearer ${p.apiKey}` }, params: { ...paramsBase } }; const pref = p.compatauth; - const ordered = - pref === "openai" || pref === "claude" - ? [cfgAuth, cfgXGoog, cfgKey] - : [cfgKey, cfgXGoog, cfgAuth]; + const ordered = (pref === "openai" || pref === "claude") ? [cfgAuth, cfgXGoog, cfgKey] : [cfgKey, cfgXGoog, cfgAuth]; const seen = new Set(); const out: any[] = []; for (const c of ordered) { const sig = JSON.stringify({ h: c.headers || {}, p: c.params || {} }); - if (!seen.has(sig)) { - seen.add(sig); - out.push(c); - } + if (!seen.has(sig)) { seen.add(sig); out.push(c); } } return out; }; @@ -805,11 +638,7 @@ const getAnthropicVersion = async (p: Provider): Promise => { let ver = "2023-06-01"; const base = trimBase(p.baseUrl); try { - await axiosWithRetry({ - method: "GET", - url: base + "/v1/models", - headers: { "x-api-key": p.apiKey }, - }); + await axiosWithRetry({ method: "GET", url: base + "/v1/models", headers: { "x-api-key": p.apiKey } }); } catch (err: any) { const txt = JSON.stringify(err?.response?.data || err?.message || ""); const matches = txt.match(/\b20\d{2}-\d{2}-\d{2}\b/g); @@ -822,9 +651,91 @@ const getAnthropicVersion = async (p: Provider): Promise => { return ver; }; +/* ---------- AI 响应清理工具 ---------- */ + +/** + * 清理 AI 思考标签(...) + * 一些模型会返回带有思考过程的响应,需要移除 + */ +const cleanAIThinking = (text: string): string => { + // 移除 ... 标签及其内容 + let cleaned = text.replace(/[\s\S]*?<\/think>/gi, ""); + // 同时处理 [think]...[/think] 格式 + cleaned = cleaned.replace(/\[think\][\s\S]*?\[\/think\]/gi, ""); + return cleaned.trim(); +}; + +/** + * 从文本中提取内嵌的 base64 图片 + * 支持 Markdown 图片格式:![alt](data:image/...;base64,...) + * 以及直接的 data URI 格式 + * @returns 提取的图片数组,包含 base64 数据和 mime 类型 + */ +const extractEmbeddedImages = (text: string): Array<{ data: string; mime: string; alt?: string }> => { + const images: Array<{ data: string; mime: string; alt?: string }> = []; + + // 匹配 Markdown 格式: ![alt](data:image/xxx;base64,...) + const mdRegex = /!\[([^\]]*)\]\((data:image\/([a-z0-9+.-]+);base64,([A-Za-z0-9+/=]+))\)/gi; + let match: RegExpExecArray | null; + while ((match = mdRegex.exec(text)) !== null) { + const alt = match[1]; + const mimeType = match[3]; // jpeg, png, webp, etc. + const base64Data = match[4]; + if (base64Data && base64Data.length > 100) { // 确保是有效的图片数据 + images.push({ data: base64Data, mime: `image/${mimeType}`, alt }); + } + } + + // 匹配直接的 data URI 格式(不在 Markdown 中) + // 避免重复匹配已经在 Markdown 中处理过的 + const dataUriRegex = /(? img.data.substring(0, 100) === base64Data.substring(0, 100)); + if (!isDuplicate && base64Data.length > 100) { + images.push({ data: base64Data, mime: `image/${mimeType}` }); + } + } + + return images; +}; + +/** + * 从文本中移除内嵌图片,只保留文字内容 + */ +const cleanEmbeddedImages = (text: string): string => { + // 移除 Markdown 图片格式 + let cleaned = text.replace(/!\[[^\]]*\]\(data:image\/[a-z0-9+.-]+;base64,[A-Za-z0-9+/=]+\)/gi, "[图片]"); + // 移除直接的 data URI + cleaned = cleaned.replace(/data:image\/[a-z0-9+.-]+;base64,[A-Za-z0-9+/=]{100,}/gi, "[图片数据]"); + return cleaned.trim(); +}; + +/** + * 处理 AI 响应,提取图片和清理文本 + * @returns 处理后的结果,包含清理后的文本和提取的图片 + */ +const processAIResponse = (rawContent: string): { text: string; images: Array<{ data: string; mime: string; alt?: string }> } => { + // 首先清理思考标签 + const withoutThinking = cleanAIThinking(rawContent); + + // 提取内嵌图片 + const images = extractEmbeddedImages(withoutThinking); + + // 清理图片数据,只保留文字 + let text = withoutThinking; + if (images.length > 0) { + text = cleanEmbeddedImages(withoutThinking); + } + + return { text, images }; +}; + /* ---------- 格式化 Q&A ---------- */ const formatQA = (qRaw: string, aRaw: string) => { - const expandAttr = Store.data.collapse ? " expandable" : ""; + const expandAttr = Store.data.collapse ? ' expandable' : ""; const qEsc = escapeAndFormatForTelegram(qRaw); const aEsc = escapeAndFormatForTelegram(aRaw); const Q = `Q:\n${qEsc}`; @@ -833,78 +744,218 @@ const formatQA = (qRaw: string, aRaw: string) => { }; /* ---------- Telegraph 工具 ---------- */ -const toNodes = (text: string) => - JSON.stringify(text.split("\n\n").map((p) => ({ tag: "p", children: [p] }))); +const toNodes = (text: string) => JSON.stringify(text.split("\n\n").map(p => ({ tag: "p", children: [p] }))); const ensureTGToken = async (): Promise => { if (Store.data.telegraph.token) return Store.data.telegraph.token; const resp = await axiosWithRetry({ method: "POST", url: "https://api.telegra.ph/createAccount", - params: { short_name: "TeleBoxAI", author_name: "TeleBox" }, + params: { short_name: "TeleBoxAI", author_name: "TeleBox" } }); const t = resp.data?.result?.access_token || ""; Store.data.telegraph.token = t; await Store.writeSoon(); return t; }; -const createTGPage = async ( - title: string, - text: string -): Promise => { - try { - const token = await ensureTGToken(); - if (!token) return null; +const createTGPage = async (title: string, text: string): Promise => { + // Telegraph 单页限制约 64KB JSON,8000安全值 + const MAX_CHARS_PER_PAGE = 8000; + + const tryCreate = async (token: string, pageTitle: string, content: string): Promise => { + const contentNodes = JSON.parse(toNodes(content)); const resp = await axiosWithRetry({ method: "POST", url: "https://api.telegra.ph/createPage", - params: { + headers: { "Content-Type": "application/json" }, + data: { access_token: token, - title, - content: toNodes(text), - return_content: false, - }, + title: pageTitle, + content: contentNodes, + return_content: false + } }); + if (!resp.data?.ok) { + return null; + } return resp.data?.result?.url || null; + }; + + // 按段落分割内容成多个块 + const splitContent = (fullText: string): string[] => { + if (fullText.length <= MAX_CHARS_PER_PAGE) return [fullText]; + + const paragraphs = fullText.split("\n\n"); + const chunks: string[] = []; + let current = ""; + + for (const para of paragraphs) { + const next = current ? current + "\n\n" + para : para; + if (next.length > MAX_CHARS_PER_PAGE && current) { + chunks.push(current); + current = para; + } else { + current = next; + } + } + if (current) chunks.push(current); + return chunks; + }; + + try { + const token = await ensureTGToken(); + if (!token) return []; + + const chunks = splitContent(text); + + + + // 多页创建 + const urls: string[] = []; + for (let i = 0; i < chunks.length; i++) { + const pageTitle = title; + try { + const url = await tryCreate(token, pageTitle, chunks[i]); + if (url) urls.push(url); + } catch { + // 页面创建失败,静默处理 + } + } + return urls; + } catch { + return []; + } +}; + + +/* ---------- 媒体处理辅助函数 ---------- */ + +// 归一化下载的媒体结果 +const normalizeDownloadedMedia = async (downloaded: any): Promise => { + if (!downloaded) return null; + if (Buffer.isBuffer(downloaded)) return downloaded; + if (typeof downloaded === "string" && downloaded.length > 0) { + try { + const stat = await fs.promises.stat(downloaded); + if (!stat.isFile()) return null; + return await fs.promises.readFile(downloaded); + } catch { + return null; + } + } + return null; +}; + +// 提取第一帧 (GIF/WebM/Sticker) +const extractFirstFrame = async (buffer: Buffer): Promise => { + try { + // animated: true 读取第一帧,转为 png + return await sharp(buffer, { animated: true }).png().toBuffer(); } catch { return null; } }; +// 获取 Document 缩略图 +const getDocumentThumb = (doc: Api.Document): Api.TypePhotoSize | undefined => { + const thumbs = doc.thumbs || []; + if (thumbs.length === 0) return undefined; + return thumbs[thumbs.length - 1]; +}; + +/** + * 智能下载并处理消息中的媒体(支持图片、GIF、贴纸等) + * 返回适合 AI 视觉模型的 Buffer (通常是 PNG/JPEG) + */ +const downloadMessageMediaAsData = async (msg: Api.Message): Promise<{ buffer: Buffer; mime: string } | null> => { + if (!msg?.media || !msg.client) return null; + + // 1. 普通 Photo + if (msg.media instanceof Api.MessageMediaPhoto) { + const downloaded = await msg.client.downloadMedia(msg); + const buffer = await normalizeDownloadedMedia(downloaded); + if (!buffer) return null; + return { buffer, mime: "image/jpeg" }; + } + + // 2. Document (可能是普通图片、GIF、贴纸) + if (msg.media instanceof Api.MessageMediaDocument && msg.media.document instanceof Api.Document) { + const doc = msg.media.document; + const docMime = (doc.mimeType || "").toLowerCase(); + const isAnimated = + docMime === "image/gif" || + docMime === "video/webm" || + docMime === "application/x-tgsticker" || + docMime === "application/x-tg-sticker" || + doc.attributes?.some((attr) => attr instanceof Api.DocumentAttributeAnimated); + + // 2.1 静态图片 Document + if (!isAnimated && docMime.startsWith("image/")) { + const downloaded = await msg.client.downloadMedia(msg); + const buffer = await normalizeDownloadedMedia(downloaded); + if (!buffer) return null; + return { buffer, mime: docMime }; + } + + // 2.2 动态媒体 (GIF / WebM / Sticker) -> 抽帧 + let frameBuffer: Buffer | null = null; + + // 优先尝试利用 Telegram 提供的缩略图 + const thumb = getDocumentThumb(doc); + if (thumb) { + try { + const downloaded = await msg.client.downloadMedia(msg, { thumb }); + const buffer = await normalizeDownloadedMedia(downloaded); + if (buffer) { + // 确保是 PNG + try { frameBuffer = await sharp(buffer).png().toBuffer(); } catch { frameBuffer = buffer; } + } + } catch { + // 缩略图下载失败,回退到原文件 + } + } + + // 如果没有缩略图或失败,尝试下载原文件并抽帧 + if (!frameBuffer) { + try { + const downloaded = await msg.client.downloadMedia(msg); + const buffer = await normalizeDownloadedMedia(downloaded); + if (buffer) { + frameBuffer = await extractFirstFrame(buffer); + } + } catch { + // 原文件下载/抽帧失败 + } + } + + if (frameBuffer) { + return { buffer: frameBuffer, mime: "image/png" }; + } + } + + return null; +}; + /* ---------- 聊天适配 ---------- */ -const chatOpenAI = async ( - p: Provider, - model: string, - msgs: { role: string; content: string }[], - maxTokens?: number, - useSearch?: boolean -) => { +const chatOpenAI = async (p: Provider, model: string, msgs: { role: string; content: string }[], maxTokens?: number, useSearch?: boolean) => { const url = trimBase(p.baseUrl) + "/v1/chat/completions"; - const body: any = { model, messages: msgs, max_tokens: maxTokens || 1024 }; + const effectiveMaxTokens = maxTokens || Store.data.maxTokens || DEFAULT_MAX_TOKENS; + const body: any = { model, messages: msgs, max_tokens: effectiveMaxTokens }; if (useSearch && p.baseUrl?.includes("api.openai.com")) { - body.tools = [ - { - type: "function", - function: { - name: "web_search", - description: - "Search the web for current information and return relevant results", - parameters: { - type: "object", - properties: { - query: { - type: "string", - description: "The search query to execute", - }, - }, - required: ["query"], - }, - }, - }, - ]; + body.tools = [{ + type: "function", + function: { + name: "web_search", + description: "Search the web for current information and return relevant results", + parameters: { + type: "object", + properties: { query: { type: "string", description: "The search query to execute" } }, + required: ["query"] + } + } + }]; } else if (useSearch) { const searchPrompt = "请基于你的知识回答以下问题,如果需要最新信息请说明。"; - msgs[msgs.length - 1].content = - searchPrompt + "\n\n" + msgs[msgs.length - 1].content; + msgs[msgs.length - 1].content = searchPrompt + "\n\n" + msgs[msgs.length - 1].content; } const attempts = buildAuthAttempts(p); try { @@ -913,38 +964,16 @@ const chatOpenAI = async ( } catch (lastErr: any) { const status = lastErr?.response?.status; const bodyErr = lastErr?.response?.data; - const msg = - bodyErr?.error?.message || - bodyErr?.message || - lastErr?.message || - String(lastErr); - throw new Error( - `[chatOpenAI] adapter=openai model=${html(model)} status=${ - status || "network" - } message=${msg}` - ); + const msg = bodyErr?.error?.message || bodyErr?.message || lastErr?.message || String(lastErr); + throw new Error(`[chatOpenAI] adapter=openai model=${html(model)} status=${status || "network"} message=${msg}`); } }; -const chatClaude = async ( - p: Provider, - model: string, - msgs: { role: string; content: string }[], - maxTokens?: number, - useSearch?: boolean -) => { +const chatClaude = async (p: Provider, model: string, msgs: { role: string; content: string }[], maxTokens?: number, useSearch?: boolean) => { const url = trimBase(p.baseUrl) + "/v1/messages"; - const body: any = { - model, - max_tokens: maxTokens || 1024, - messages: msgs.map((m) => ({ - role: m.role === "assistant" ? "assistant" : "user", - content: m.content, - })), - }; + const effectiveMaxTokens = maxTokens || Store.data.maxTokens || DEFAULT_MAX_TOKENS; + const body: any = { model, max_tokens: effectiveMaxTokens, messages: msgs.map(m => ({ role: m.role === "assistant" ? "assistant" : "user", content: m.content })) }; if (useSearch && p.baseUrl?.includes("api.anthropic.com")) { - body.tools = [ - { type: "web_search_20241220", name: "web_search", max_uses: 3 }, - ]; + body.tools = [{ type: "web_search_20241220", name: "web_search", max_uses: 3 }]; } const v = await getAnthropicVersion(p); const attempts = buildAuthAttempts(p, { "anthropic-version": v }); @@ -965,66 +994,43 @@ const chatClaude = async ( data?.text, data?.content, data?.message?.content, - data?.output, + data?.output ]; - for (const text of possibleTexts) - if (typeof text === "string" && text.trim()) return text.trim(); + for (const text of possibleTexts) if (typeof text === "string" && text.trim()) return text.trim(); return ""; } catch (lastErr: any) { const status = lastErr?.response?.status; const bodyErr = lastErr?.response?.data; - const msg = - bodyErr?.error?.message || - bodyErr?.message || - lastErr?.message || - String(lastErr); - throw new Error( - `[chatClaude] adapter=claude model=${html(model)} status=${ - status || "network" - } message=${msg}` - ); + const msg = bodyErr?.error?.message || bodyErr?.message || lastErr?.message || String(lastErr); + throw new Error(`[chatClaude] adapter=claude model=${html(model)} status=${status || "network"} message=${msg}`); } }; -const chatGemini = async ( - p: Provider, - model: string, - msgs: { role: string; content: string }[], - useSearch: boolean = false -) => { +const chatGemini = async (p: Provider, model: string, msgs: { role: string; content: string }[], useSearch: boolean = false) => { const path = `/models/${encodeURIComponent(model)}:generateContent`; + const effectiveMaxTokens = Store.data.maxTokens || DEFAULT_MAX_TOKENS; const requestData: any = { - contents: [ - { - parts: msgs.map((m) => ({ - text: (m.role === "user" ? "" : "") + m.content, - })), - }, - ], + contents: [{ parts: msgs.map(m => ({ text: m.content })) }], + generationConfig: { + maxOutputTokens: effectiveMaxTokens, + temperature: 0.9 + } }; if (useSearch) requestData.tools = [{ googleSearch: {} }]; const data = await geminiRequestWithFallback(p, path, { method: "POST", data: requestData, - params: { key: p.apiKey }, + params: { key: p.apiKey } }); const parts = data?.candidates?.[0]?.content?.parts || []; return parts.map((x: any) => x.text || "").join(""); }; /* ---------- 视觉对话 ---------- */ -const chatVisionOpenAI = async ( - p: Provider, - model: string, - imageB64: string, - prompt?: string -) => { +const chatVisionOpenAI = async (p: Provider, model: string, imageB64: string, prompt?: string, mime?: string) => { const url = trimBase(p.baseUrl) + "/v1/chat/completions"; const content = [ { type: "text", text: prompt || "用中文描述此图片" }, - { - type: "image_url", - image_url: { url: `data:image/png;base64,${imageB64}` }, - }, + { type: "image_url", image_url: { url: `data:${mime || "image/png"};base64,${imageB64}` } } ]; const body = { model, messages: [{ role: "user", content }] }; const attempts = buildAuthAttempts(p); @@ -1034,24 +1040,11 @@ const chatVisionOpenAI = async ( } catch (lastErr: any) { const status = lastErr?.response?.status; const bodyErr = lastErr?.response?.data; - const msg = - bodyErr?.error?.message || - bodyErr?.message || - lastErr?.message || - String(lastErr); - throw new Error( - `[chatVisionOpenAI] adapter=openai model=${html(model)} status=${ - status || "network" - } message=${msg}` - ); + const msg = bodyErr?.error?.message || bodyErr?.message || lastErr?.message || String(lastErr); + throw new Error(`[chatVisionOpenAI] adapter=openai model=${html(model)} status=${status || "network"} message=${msg}`); } }; -const chatVisionGemini = async ( - p: Provider, - model: string, - imageB64: string, - prompt?: string -) => { +const chatVisionGemini = async (p: Provider, model: string, imageB64: string, prompt?: string, mime?: string) => { const path = `/models/${encodeURIComponent(model)}:generateContent`; try { const data = await geminiRequestWithFallback(p, path, { @@ -1061,34 +1054,24 @@ const chatVisionGemini = async ( { role: "user", parts: [ - { inlineData: { mimeType: "image/png", data: imageB64 } }, - { text: prompt || "用中文描述此图片" }, - ], - }, - ], + { inlineData: { mimeType: mime || "image/png", data: imageB64 } }, + { text: prompt || "用中文描述此图片" } + ] + } + ] }, - params: { key: p.apiKey }, + params: { key: p.apiKey } }); const parts = data?.candidates?.[0]?.content?.parts || []; return parts.map((x: any) => x.text || "").join(""); } catch (err: any) { const status = err?.response?.status; const body = err?.response?.data; - const msg = - body?.error?.message || body?.message || err?.message || String(err); - throw new Error( - `[chatVisionGemini] adapter=gemini model=${html(model)} status=${ - status || "network" - } message=${msg}` - ); + const msg = body?.error?.message || body?.message || err?.message || String(err); + throw new Error(`[chatVisionGemini] adapter=gemini model=${html(model)} status=${status || "network"} message=${msg}`); } }; -const chatVisionClaude = async ( - p: Provider, - model: string, - imageB64: string, - prompt?: string -) => { +const chatVisionClaude = async (p: Provider, model: string, imageB64: string, prompt?: string, mime?: string) => { const url = trimBase(p.baseUrl) + "/v1/messages"; const v = await getAnthropicVersion(p); const body = { @@ -1099,119 +1082,172 @@ const chatVisionClaude = async ( role: "user", content: [ { type: "text", text: prompt || "用中文描述此图片" }, - { - type: "image", - source: { type: "base64", media_type: "image/png", data: imageB64 }, - }, - ], - }, - ], + { type: "image", source: { type: "base64", media_type: mime || "image/png", data: imageB64 } } + ] + } + ] }; const attempts = buildAuthAttempts(p, { "anthropic-version": v }); try { const data: any = await tryPostJSON(url, body, attempts); const blocks = data?.content || data?.message?.content || []; - return Array.isArray(blocks) - ? blocks.map((b: any) => b?.text || b?.content?.[0]?.text || "").join("") - : ""; + return Array.isArray(blocks) ? blocks.map((b: any) => b?.text || b?.content?.[0]?.text || "").join("") : ""; } catch (lastErr: any) { const status = lastErr?.response?.status; const bodyErr = lastErr?.response?.data; - const msg = - bodyErr?.error?.message || - bodyErr?.message || - lastErr?.message || - String(lastErr); - throw new Error( - `[chatVisionClaude] adapter=claude model=${html(model)} status=${ - status || "network" - } message=${msg}` - ); + const msg = bodyErr?.error?.message || bodyErr?.message || lastErr?.message || String(lastErr); + throw new Error(`[chatVisionClaude] adapter=claude model=${html(model)} status=${status || "network"} message=${msg}`); } }; -const chatVision = async ( - p: Provider, - compat: string, - model: string, - imageB64: string, - prompt?: string -): Promise => { - if (compat === "openai") return chatVisionOpenAI(p, model, imageB64, prompt); - if (compat === "gemini") return chatVisionGemini(p, model, imageB64, prompt); - if (compat === "claude") return chatVisionClaude(p, model, imageB64, prompt); - return chatOpenAI(p, model, [ - { role: "user", content: prompt || "描述这张图片" } as any, - ] as any); +const chatVision = async (p: Provider, compat: string, model: string, imageB64: string, prompt?: string, mime?: string): Promise => { + if (compat === "openai") return chatVisionOpenAI(p, model, imageB64, prompt, mime); + if (compat === "gemini") return chatVisionGemini(p, model, imageB64, prompt, mime); + if (compat === "claude") return chatVisionClaude(p, model, imageB64, prompt, mime); + return chatOpenAI(p, model, [{ role: "user", content: prompt || "描述这张图片" } as any] as any); }; /* ---------- 生图 ---------- */ const imageOpenAI = async ( p: Provider, model: string, - prompt: string + prompt: string, + sourceImage?: { data: string; mime: string } ): Promise => { - const url = trimBase(p.baseUrl) + "/v1/images/generations"; - const body = { + const base = trimBase(p.baseUrl); + const isEdit = !!sourceImage; + + // 尝试多种方式:优先使用 generations 端点(大多数第三方兼容) + // 如果有源图片,将图片 base64 嵌入请求体(某些平台如豆包支持此方式) + const url = base + "/v1/images/generations"; + + // 根据模型选择合适的分辨率 + // 某些模型(如豆包 imagen)要求最小 3686400 像素 (1920x1920) + // 通用模型使用 1024x1024 + const modelLower = model.toLowerCase(); + const needsHighRes = modelLower.includes("imagen") || + modelLower.includes("sd3") || + modelLower.includes("sdxl") || + modelLower.includes("flux") || + modelLower.includes("seedream") || + modelLower.includes("doubao"); + const imageSize = needsHighRes ? "2048x2048" : "1024x1024"; + + // 构建请求体 + let body: any = { model, prompt, n: 1, response_format: "b64_json", - size: "1024x1024", + size: imageSize }; + + // 如果有源图片,添加到请求体(兼容某些支持图生图的第三方平台) + if (isEdit && sourceImage) { + body.image = `data:${sourceImage.mime};base64,${sourceImage.data}`; + } + const attempts = buildAuthAttempts(p, { "Content-Type": "application/json" }); - const data = await tryPostJSON(url, body, attempts); - const first = data?.data?.[0] || {}; - const b64 = first?.b64_json || first?.image_base64 || first?.image || ""; - if (b64) return String(b64); - const urlOut = first?.url || first?.image_url; - if (urlOut) { - try { - const r = await axiosWithRetry({ - method: "GET", - url: String(urlOut), - responseType: "arraybuffer", - }); - const buf: any = r.data; - const b: Buffer = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); - if (b && b.length > 0) return b.toString("base64"); - } catch {} + + try { + const data = await tryPostJSON(url, body, attempts); + const first = data?.data?.[0] || {}; + const b64 = first?.b64_json || first?.image_base64 || first?.image || ""; + if (b64) return String(b64); + const urlOut = first?.url || first?.image_url; + if (urlOut) { + try { + const r = await axiosWithRetry({ method: "GET", url: String(urlOut), responseType: "arraybuffer" }); + const buf: any = r.data; + const b: Buffer = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); + if (b && b.length > 0) return b.toString("base64"); + } catch { } + } + return ""; + } catch (err: any) { + // 如果 generations 端点不支持图生图,尝试 edits 端点 (标准 OpenAI 格式) + if (isEdit && sourceImage) { + try { + const editUrl = base + "/v1/images/edits"; + const editBody = { + model, + prompt, + image: `data:${sourceImage.mime};base64,${sourceImage.data}`, + n: 1, + response_format: "b64_json", + size: imageSize + }; + const editData = await tryPostJSON(editUrl, editBody, attempts); + const first = editData?.data?.[0] || {}; + const b64 = first?.b64_json || first?.image_base64 || first?.image || ""; + if (b64) return String(b64); + const urlOut = first?.url || first?.image_url; + if (urlOut) { + const r = await axiosWithRetry({ method: "GET", url: String(urlOut), responseType: "arraybuffer" }); + const buf: any = r.data; + const b: Buffer = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); + if (b && b.length > 0) return b.toString("base64"); + } + } catch { } + } + throw err; } - return ""; }; -const imageGemini = async ( - p: Provider, - model: string, - prompt: string -): Promise<{ image?: Buffer; text?: string; mime?: string }> => { +const imageGemini = async (p: Provider, model: string, prompt: string, sourceImage?: { data: string; mime: string }): Promise<{ image?: Buffer; text?: string; mime?: string }> => { let imageModel = model; - if ( - !model.includes("image") && - !model.includes("2.5-flash") && - !model.includes("2.0-flash") - ) { + if (!model.includes("image") && !model.includes("2.5-flash") && !model.includes("2.0-flash") && !model.includes("3-pro")) { imageModel = "gemini-2.5-flash-image-preview"; } const path = `/models/${encodeURIComponent(imageModel)}:generateContent`; + + // 构建请求内容 - 支持图生图 + const parts: any[] = []; + if (sourceImage) { + // 图生图:先添加原图,再添加提示词 + parts.push({ + inlineData: { + mimeType: sourceImage.mime, + data: sourceImage.data + } + }); + } + parts.push({ text: prompt }); + try { const data = await geminiRequestWithFallback(p, path, { method: "POST", data: { - contents: [{ role: "user", parts: [{ text: prompt }] }], - generationConfig: { - responseModalities: ["TEXT", "IMAGE"], - temperature: 0.7, - maxOutputTokens: 2048, - }, + contents: [{ role: "user", parts }], + generationConfig: { responseModalities: ["TEXT", "IMAGE"], temperature: 0.7, maxOutputTokens: 2048 } }, - params: { key: p.apiKey }, + params: { key: p.apiKey } }); - const parts = data?.candidates?.[0]?.content?.parts || []; + const responseParts = data?.candidates?.[0]?.content?.parts || []; let text: string | undefined; let image: Buffer | undefined; let mime: string | undefined; - for (const part of parts) { + for (const part of responseParts) { const pAny: any = part; - if (pAny?.text) text = String(pAny.text); + if (pAny?.text) { + const rawText = String(pAny.text); + // 清理思考标签 + const cleanedText = cleanAIThinking(rawText); + + // 尝试从文本中提取内嵌的 data URI 图片 + const embeddedImages = extractEmbeddedImages(cleanedText); + if (embeddedImages.length > 0) { + // 使用第一张提取到的图片 + const firstImg = embeddedImages[0]; + image = Buffer.from(firstImg.data, "base64"); + mime = firstImg.mime; + // 清理图片数据后的文本 + const remainingText = cleanEmbeddedImages(cleanedText).replace(/\[图片\]/g, "").replace(/\[图片数据\]/g, "").trim(); + if (remainingText && remainingText.length > 10) { + text = remainingText; + } + } else { + text = cleanedText; + } + } const inline = pAny?.inlineData || pAny?.inline_data; if (inline?.data) { image = Buffer.from(inline.data, "base64"); @@ -1225,26 +1261,14 @@ const imageGemini = async ( } return { image, text, mime }; } catch (err: any) { - const status = err?.response?.status; const body = err?.response?.data; - const msg = - body?.error?.message || body?.message || err?.message || String(err); - console.error( - `[imageGemini] 图片生成失败: model=${imageModel} status=${ - status || "network" - } message=${msg}` - ); + const msg = body?.error?.message || body?.message || err?.message || String(err); throw new Error(`图片生成失败:${msg}`); } }; /* ---------- TTS ---------- */ -const ttsGemini = async ( - p: Provider, - model: string, - input: string, - voiceName?: string -): Promise<{ audio?: Buffer; mime?: string }> => { +const ttsGemini = async (p: Provider, model: string, input: string, voiceName?: string): Promise<{ audio?: Buffer; mime?: string }> => { const path = `/models/${encodeURIComponent(model)}:generateContent`; const voice = voiceName || "Kore"; const buildPayloads = () => [ @@ -1252,25 +1276,24 @@ const ttsGemini = async ( contents: [{ role: "user", parts: [{ text: input }] }], generationConfig: { responseModalities: ["AUDIO"], - speechConfig: { - voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } }, - }, - }, + speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } } } + } }, { contents: [{ role: "user", parts: [{ text: input }] }], - generationConfig: { responseModalities: ["AUDIO"] }, - }, + generationConfig: { responseModalities: ["AUDIO"] } + } ]; try { - for (let i = 0; i < buildPayloads().length; i++) { - const payload = buildPayloads()[i]; + const payloads = buildPayloads(); + for (let i = 0; i < payloads.length; i++) { + const payload = payloads[i]; try { const data = await geminiRequestWithFallback(p, path, { method: "POST", data: payload, params: { key: p.apiKey }, - timeout: 60000, + timeout: 60000 }); const parts = data?.candidates?.[0]?.content?.parts || []; for (const part of parts) { @@ -1284,23 +1307,16 @@ const ttsGemini = async ( return { audio, mime }; } } - } catch (e) { - console.warn(`[ttsGemini] Payload ${i + 1} 失败`); + } catch { + // Payload 失败,尝试下一个 } } - console.warn(`[ttsGemini] 所有payload都失败,返回空结果`); return {}; - } catch (e: any) { - console.error(`[ttsGemini] 整体异常:`, e?.message || e); + } catch { return {}; } }; -const ttsOpenAI = async ( - p: Provider, - model: string, - input: string, - voiceName?: string -): Promise => { +const ttsOpenAI = async (p: Provider, model: string, input: string, voiceName?: string): Promise => { const base = trimBase(p.baseUrl); const paths = ["/v1/audio/speech", "/v1/audio/tts", "/audio/speech"]; const payload = { model, input, voice: voiceName || "alloy", format: "opus" }; @@ -1310,14 +1326,7 @@ const ttsOpenAI = async ( const url = base + pth; for (const a of attempts) { try { - const r = await axiosWithRetry({ - method: "POST", - url, - data: payload, - responseType: "arraybuffer", - ...(a || {}), - timeout: 60000, - }); + const r = await axiosWithRetry({ method: "POST", url, data: payload, responseType: "arraybuffer", ...(a || {}), timeout: 60000 }); const data: any = r.data; const buf: Buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); if (buf && buf.length > 0) return buf; @@ -1328,56 +1337,35 @@ const ttsOpenAI = async ( } const status = lastErr?.response?.status; const bodyErr = lastErr?.response?.data; - const msg = - bodyErr?.error?.message || - bodyErr?.message || - lastErr?.message || - String(lastErr); - throw new Error( - `[ttsOpenAI] adapter=openai model=${html(model)} status=${ - status || "network" - } message=${msg}` - ); + const msg = bodyErr?.error?.message || bodyErr?.message || lastErr?.message || String(lastErr); + throw new Error(`[ttsOpenAI] adapter=openai model=${html(model)} status=${status || "network"} message=${msg}`); }; /* ---------- PCM -> WAV ---------- */ -const convertPcmL16ToWavIfNeeded = ( - raw: Buffer, - mime?: string -): { buf: Buffer; mime: string } => { +const convertPcmL16ToWavIfNeeded = (raw: Buffer, mime?: string): { buf: Buffer; mime: string } => { let buf = raw; let outMime = mime || "audio/ogg"; const lm = outMime.toLowerCase(); if (lm.includes("l16") && lm.includes("pcm")) { try { const parse = (mt: string) => { - const [fileType, ...params] = mt.split(";").map((s) => s.trim()); + const [fileType, ...params] = mt.split(";").map(s => s.trim()); const [, format] = (fileType || "").split("/"); - const opts: any = { - numChannels: 1, - sampleRate: 24000, - bitsPerSample: 16, - }; + const opts: any = { numChannels: 1, sampleRate: 24000, bitsPerSample: 16 }; if (format && format.toUpperCase().startsWith("L")) { const bits = parseInt(format.slice(1), 10); if (!isNaN(bits)) opts.bitsPerSample = bits; } for (const param of params) { - const [k, v] = param.split("=").map((s) => s.trim()); - if (k === "rate") { - const r = parseInt(v, 10); - if (!isNaN(r)) opts.sampleRate = r; - } - if (k === "channels") { - const c = parseInt(v, 10); - if (!isNaN(c)) opts.numChannels = c; - } + const [k, v] = param.split("=").map(s => s.trim()); + if (k === "rate") { const r = parseInt(v, 10); if (!isNaN(r)) opts.sampleRate = r; } + if (k === "channels") { const c = parseInt(v, 10); if (!isNaN(c)) opts.numChannels = c; } } return opts; }; const createHeader = (len: number, o: any) => { - const byteRate = (o.sampleRate * o.numChannels * o.bitsPerSample) / 8; - const blockAlign = (o.numChannels * o.bitsPerSample) / 8; + const byteRate = o.sampleRate * o.numChannels * o.bitsPerSample / 8; + const blockAlign = o.numChannels * o.bitsPerSample / 8; const b = Buffer.alloc(44); b.write("RIFF", 0); b.writeUInt32LE(36 + len, 4); @@ -1398,18 +1386,13 @@ const convertPcmL16ToWavIfNeeded = ( const header = createHeader(buf.length, opts); buf = Buffer.concat([header, buf]); outMime = "audio/wav"; - } catch {} + } catch { } } return { buf, mime: outMime }; }; /* ---------- 语音发送 ---------- */ -const sendVoiceWithCaption = async ( - msg: Api.Message, - fileBuf: Buffer, - caption: string, - replyToId?: number -): Promise => { +const sendVoiceWithCaption = async (msg: Api.Message, fileBuf: Buffer, caption: string, replyToId?: number): Promise => { try { const file: any = Object.assign(fileBuf, { name: "ai.ogg" }); await msg.client?.sendFile(msg.peerId, { @@ -1417,43 +1400,26 @@ const sendVoiceWithCaption = async ( caption, parseMode: "html", replyTo: replyToId || undefined, - attributes: [ - new Api.DocumentAttributeAudio({ duration: 0, voice: true }), - ], + attributes: [new Api.DocumentAttributeAudio({ duration: 0, voice: true })] }); } catch (error: any) { - if ( - error?.message?.includes("CHAT_SEND_VOICES_FORBIDDEN") || - error?.message?.includes("VOICES_FORBIDDEN") - ) { - console.warn( - "[AI] Voice sending forbidden, retrying as regular audio/document" - ); + if (error?.message?.includes("CHAT_SEND_VOICES_FORBIDDEN") || error?.message?.includes("VOICES_FORBIDDEN")) { try { const altFile: any = Object.assign(fileBuf, { name: "ai.wav" }); await msg.client?.sendFile(msg.peerId, { file: altFile, caption, parseMode: "html", - replyTo: replyToId || undefined, + replyTo: replyToId || undefined }); return; - } catch (e2: any) { - console.warn( - "[AI] Fallback to regular audio/document failed, falling back to text" - ); + } catch { + // 回退到文本消息 const fallbackText = caption + "\n\n⚠️ 语音发送被禁止,已转为文本消息"; if (replyToId) { - await msg.client?.sendMessage(msg.peerId, { - message: fallbackText, - parseMode: "html", - replyTo: replyToId, - }); + await msg.client?.sendMessage(msg.peerId, { message: fallbackText, parseMode: "html", replyTo: replyToId }); } else { - await msg.client?.sendMessage(msg.peerId, { - message: fallbackText, - parseMode: "html", - }); + await msg.client?.sendMessage(msg.peerId, { message: fallbackText, parseMode: "html" }); } } } else { @@ -1463,42 +1429,49 @@ const sendVoiceWithCaption = async ( }; /* ---------- 图片发送 ---------- */ -const sendImageFile = async ( - msg: Api.Message, - buf: Buffer, - caption: string, - replyToId?: number, - mimeHint?: string -): Promise => { - const ext = (mimeHint || "image/png").includes("png") - ? "png" - : (mimeHint || "").includes("jpeg") - ? "jpg" - : "png"; +const sendImageFile = async (msg: Api.Message, buf: Buffer, caption: string, replyToId?: number, mimeHint?: string): Promise => { + const ext = (mimeHint || "image/png").includes("png") ? "png" : (mimeHint || "").includes("jpeg") ? "jpg" : "png"; const file: any = Object.assign(buf, { name: `ai.${ext}` }); - await msg.client?.sendFile(msg.peerId, { - file, - caption, - parseMode: "html", - replyTo: replyToId || undefined, - }); + await msg.client?.sendFile(msg.peerId, { file, caption, parseMode: "html", replyTo: replyToId || undefined }); }; /* ---------- 长文自动选择 ---------- */ -const sendLongAuto = async ( - msg: Api.Message, - text: string, - replyToId?: number, - opts?: { collapse?: boolean }, - postfix?: string -): Promise => { - if (replyToId) { - await sendLongReply(msg, replyToId, text, opts, postfix); - } else { - await sendLong(msg, text, opts, postfix); +const sendLongAuto = async (msg: Api.Message, text: string, replyToId?: number, opts?: { collapse?: boolean }, postfix?: string): Promise => { + if (replyToId) await sendLongReply(msg, replyToId, text, opts, postfix); + else await sendLong(msg, text, opts, postfix); +}; + +// 公共 TTS 执行函数 +const executeTTS = async (msg: Api.Message, text: string, replyToId: number): Promise => { + const m = pick("tts"); + if (!m) { await msg.edit({ text: "❌ 未设置 tts 模型", parseMode: "html" }); return false; } + const p = providerOf(m.provider); + if (!p?.apiKey) { await msg.edit({ text: "❌ 服务商/令牌未配置", parseMode: "html" }); return false; } + const compat = await resolveCompat(m.provider, m.model, p); + if (!Store.data.voices) Store.data.voices = { gemini: "Kore", openai: "alloy" }; + const voice = compat === "gemini" ? Store.data.voices.gemini : Store.data.voices.openai; + await msg.edit({ text: "🔊 合成中...", parseMode: "html" }); + try { + if (compat === "openai") { + const audio = await ttsOpenAI(p, m.model, text, voice); + await sendVoiceWithCaption(msg, audio, "", replyToId); + } else if (compat === "gemini") { + const { audio, mime } = await ttsGemini(p, m.model, text, voice); + if (!audio) { await msg.edit({ text: "❌ 语音合成失败", parseMode: "html" }); return false; } + const { buf } = convertPcmL16ToWavIfNeeded(audio, mime); + await sendVoiceWithCaption(msg, buf, "", replyToId); + } else { + await msg.edit({ text: "❌ 当前服务商不支持语音合成", parseMode: "html" }); return false; + } + await msg.delete(); + return true; + } catch (e: any) { + await msg.edit({ text: `❌ 语音合成失败: ${html(e?.message || e)}`, parseMode: "html" }); + return false; } }; + /* ---------- 模型列表解析 ---------- */ const parseModelListFromResponse = (data: any): string[] => { const arr = Array.isArray(data) ? data : data?.data || data?.models || []; @@ -1508,15 +1481,8 @@ const parseModelListFromResponse = (data: any): string[] => { /* ---------- 按兼容类型枚举模型 ---------- */ const listModels = async (p: Provider, compat: Compat): Promise => { const base = trimBase(p.baseUrl); - const tryGet = async ( - url: string, - headers: Record = {}, - prefer?: Compat - ) => { - const attempts = buildAuthAttempts( - { ...p, compatauth: prefer || p.compatauth } as Provider, - headers - ); + const tryGet = async (url: string, headers: Record = {}, prefer?: Compat) => { + const attempts = buildAuthAttempts({ ...p, compatauth: prefer || p.compatauth } as Provider, headers); let lastErr: any; for (const a of attempts) { try { @@ -1598,14 +1564,7 @@ const listModels = async (p: Provider, compat: Compat): Promise => { if (lastErr) throw lastErr; throw new Error("无法获取模型列表:服务无有效输出"); }; -const listModelsByAnyCompat = async ( - p: Provider -): Promise<{ - models: string[]; - compat: Compat | null; - compats: Compat[]; - modelMap?: Record; -}> => { +const listModelsByAnyCompat = async (p: Provider): Promise<{ models: string[]; compat: Compat | null; compats: Compat[]; modelMap?: Record }> => { const order: Compat[] = ["openai", "gemini", "claude"]; const merged = new Map(); const compats: Compat[] = []; @@ -1623,19 +1582,13 @@ const listModelsByAnyCompat = async ( if (k && modelMap[k] === undefined) modelMap[k] = c; } } - } catch {} + } catch { } } for (const k of Object.keys(modelMap)) { - const g = detectCompat("", k, ""); - if ((g === "gemini" || g === "claude") && modelMap[k] !== g) - modelMap[k] = g; + const g = detectCompat(k); + if ((g === "gemini" || g === "claude") && modelMap[k] !== g) modelMap[k] = g; } - return { - models: Array.from(merged.values()), - compat: primary, - compats, - modelMap, - }; + return { models: Array.from(merged.values()), compat: primary, compats, modelMap }; }; /* ---------- 预设 Prompt 应用 ---------- */ @@ -1646,11 +1599,7 @@ const applyPresetPrompt = (userInput: string): string => { }; /* ---------- 统一聊天调用 ---------- */ -const callChat = async ( - kind: "chat" | "search", - text: string, - msg: Api.Message -): Promise<{ content: string; model: string }> => { +const callChat = async (kind: "chat" | "search", text: string, msg: Api.Message): Promise<{ content: string; model: string }> => { const m = pick(kind); if (!m) throw new Error(`未设置${kind}模型,请先配置`); const p = providerOf(m.provider); @@ -1663,18 +1612,12 @@ const callChat = async ( let out = ""; try { const isSearch = kind === "search"; - if (compat === "openai") - out = await chatOpenAI(p, m.model, msgs, undefined, isSearch); - else if (compat === "claude") - out = await chatClaude(p, m.model, msgs, undefined, isSearch); + if (compat === "openai") out = await chatOpenAI(p, m.model, msgs, undefined, isSearch); + else if (compat === "claude") out = await chatClaude(p, m.model, msgs, undefined, isSearch); else out = await chatGemini(p, m.model, msgs, isSearch); } catch (e: any) { const em = e?.message || String(e); - throw new Error( - `[${kind}] provider=${m.provider} compat=${compat} model=${html( - m.model - )} :: ${em}` - ); + throw new Error(`[${kind}] provider=${m.provider} compat=${compat} model=${html(m.model)} :: ${em}`); } if (Store.data.contextEnabled) { pushHist(id, "user", text); @@ -1684,6 +1627,8 @@ const callChat = async ( return { content: out, model: m.model }; }; + + /* ---------- 帮助文案 ---------- */ const help_text = `🔧 📝 特性 兼容 Google Gemini、OpenAI、Anthropic Claude、Baidu 标准接口,统一指令,一处配置,多处可用。 @@ -1694,96 +1639,111 @@ const help_text = `🔧 📝 特性 • 🎯 全局Prompt预设:为所有对话设置统一的系统提示词
💬 对话 -ai chat [问题] -• 示例:ai chat 你好,帮我简单介绍一下你 -• 支持多轮对话(可执行 ai context on 开启记忆) +${mainPrefix}ai chat [问题] +• 示例:${mainPrefix}ai chat 你好,帮我简单介绍一下你 +• 支持多轮对话(可执行 ${mainPrefix}ai context on 开启记忆) • 超长回答可自动转 Telegraph 🔍 搜索 -ai search [查询] -• 示例:ai search 2024 年 AI 技术进展 +${mainPrefix}ai search [查询] +• 示例:${mainPrefix}ai search 2024 年 AI 技术进展 🖼️ 图片 -ai image [描述] -• 示例:ai image 未来城市的科幻夜景 +${mainPrefix}ai image [描述] +• 示例:${mainPrefix}ai image 未来城市的科幻夜景 🎵 文本转语音 -ai tts [文本] -• 示例:ai tts 你好,这是一次语音合成测试 +${mainPrefix}ai tts [文本] +• 示例:${mainPrefix}ai tts 你好,这是一次语音合成测试 🎤 语音回答 -ai audio [问题] -• 示例:ai audio 用 30 秒介绍人工智能的发展 +${mainPrefix}ai audio [问题] +• 示例:${mainPrefix}ai audio 用 30 秒介绍人工智能的发展 🔍🎤 搜索并语音回答 -ai searchaudio [查询] -• 示例:ai searchaudio 2024 年最新科技趋势 +${mainPrefix}ai searchaudio [查询] +• 示例:${mainPrefix}ai searchaudio 2024 年最新科技趋势 🎯 全局Prompt预设 -ai prompt set [内容] - 设置全局Prompt预设 -ai prompt clear - 清除全局Prompt预设 -ai prompt show - 显示当前Prompt预设 +${mainPrefix}ai prompt set [内容] - 设置全局Prompt预设 +${mainPrefix}ai prompt clear - 清除全局Prompt预设 +${mainPrefix}ai prompt show - 显示当前Prompt预设 • 预设将自动添加到所有对话请求前,适用于角色设定、回答风格等统一配置 💭 对话上下文 -ai context on|off|show|del +${mainPrefix}ai context on|off|show|del 📋 消息折叠 -ai collapse on|off +${mainPrefix}ai collapse on|off 📰 Telegraph 长文 -ai telegraph on|off|limit <数量>|list|del <n|all> +${mainPrefix}ai telegraph on|off|limit <数量>|list|del <n|all> • limit <数量>:设置字数阈值(0 表示不限制) • 自动创建 / 管理 / 删除 Telegraph 文章 🎤 音色管理 -ai voice list - 查看所有可用音色(Gemini 30种 / OpenAI 6种) -ai voice show - 查看当前音色配置 -ai voice gemini [音色名] - 设置 Gemini TTS 音色 -ai voice openai [音色名] - 设置 OpenAI TTS 音色 +${mainPrefix}ai voice list - 查看所有可用音色(Gemini 30种 / OpenAI 6种) +${mainPrefix}ai voice show - 查看当前音色配置 +${mainPrefix}ai voice gemini [音色名] - 设置 Gemini TTS 音色 +${mainPrefix}ai voice openai [音色名] - 设置 OpenAI TTS 音色 • Gemini 音色示例:Kore, Puck, Charon, Leda, Aoede 等 • OpenAI 音色示例:alloy, echo, fable, onyx, nova, shimmer ⚙️ 模型管理 -ai model list - 查看当前模型配置 -ai model chat|search|image|tts [服务商] [模型] - 设置各功能模型 -ai model default - 清空所有功能模型 -ai model auto - 智能分配 chat/search/image/tts +${mainPrefix}ai model list - 查看当前模型配置 +${mainPrefix}ai model chat|search|image|tts [服务商] [模型] - 设置各功能模型 +${mainPrefix}ai model default - 清空所有功能模型 +${mainPrefix}ai model auto - 智能分配 chat/search/image/tts 🔧 配置管理 -ai config status - 显示配置概览 -ai config add [服务商] [API密钥] [BaseURL] -ai config list - 查看已配置的服务商 -ai config model [服务商] - 查看该服务商可用模型 -ai config update [服务商] [apikey|baseurl] [值] -ai config remove [服务商|all] +${mainPrefix}ai config status - 显示配置概览 +${mainPrefix}ai config add [服务商] [API密钥] [BaseURL] +${mainPrefix}ai config list - 查看已配置的服务商 +${mainPrefix}ai config model [服务商] - 查看该服务商可用模型 +${mainPrefix}ai config update [服务商] [apikey|baseurl] [值] +${mainPrefix}ai config remove [服务商|all] 📝 配置示例 -• OpenAI:ai config add openai sk-proj-xxx https://api.openai.com -• DeepSeek:ai config add deepseek sk-xxx https://api.deepseek.com -• Grok:ai config add grok xai-xxx https://api.x.ai -• Claude:ai config add claude sk-ant-xxx https://api.anthropic.com -• Gemini:ai config add gemini AIzaSy-xxx https://generativelanguage.googleapis.com +• OpenAI:${mainPrefix}ai config add openai sk-proj-xxx https://api.openai.com +• DeepSeek:${mainPrefix}ai config add deepseek sk-xxx https://api.deepseek.com +• Grok:${mainPrefix}ai config add grok xai-xxx https://api.x.ai +• Claude:${mainPrefix}ai config add claude sk-ant-xxx https://api.anthropic.com +• Gemini:${mainPrefix}ai config add gemini AIzaSy-xxx https://generativelanguage.googleapis.com简洁命令与别名 常用简写 -• 对话:ai [问题]ai chat [问题] -• 搜索:ai s [查询] -• 图片:ai img [描述] -• 语音:ai v [文本] -• 回答为语音:ai a [问题] / 搜索并语音:ai sa [查询] -• 上下文:ai ctx on|off -• 模型:ai m list / 设置:ai m chat|search|image|tts [服务商] [模型] -• 配置:ai c add [服务商] [API密钥] [BaseURL] +• 对话:${mainPrefix}ai [问题]${mainPrefix}ai chat [问题] +• 搜索:${mainPrefix}ai s [查询] +• 图片:${mainPrefix}ai img [描述] +• 语音:${mainPrefix}ai v [文本] +• 回答为语音:${mainPrefix}ai a [问题] / 搜索并语音:${mainPrefix}ai sa [查询] +• 上下文:${mainPrefix}ai ctx on|off +• 模型:${mainPrefix}ai m list / 设置:${mainPrefix}ai m chat|search|image|tts [服务商] [模型] +• 配置:${mainPrefix}ai c add [服务商] [API密钥] [BaseURL] • 别名:s=search, img/i=image, v=tts, a=audio, sa=searchaudio, ctx=context, fold=collapse, cfg/c=config, m=model + +⏱️ 超时设置 +${mainPrefix}ai timeout - 查看当前超时时间 +${mainPrefix}ai timeout set [秒] - 设置超时时间(10-600秒) +${mainPrefix}ai timeout reset - 重置为默认值(30秒) + +📝 最大输出 Token +${mainPrefix}ai maxtokens - 查看当前设置 +${mainPrefix}ai maxtokens set [数量] - 设置最大输出 token(100-128000) +${mainPrefix}ai maxtokens reset - 重置为默认值(16384,约8000中文字) +• 生成超长文本时需增大此值,同时建议增加超时时间 + +🔗 链接预览 +${mainPrefix}ai preview - 查看当前状态 +${mainPrefix}ai preview on|off - 开启/关闭链接预览
`; + /* ---------- 插件主体 ---------- */ -const CMD_AI = "ai" as const; class AiPlugin extends Plugin { description = `🤖 智能AI助手\n\n${help_text}`; cmdHandlers = { - [CMD_AI]: async (msg: Api.Message) => { + ai: async (msg: Api.Message) => { await Store.init(); ensureDir(); const text = (msg as any).text || (msg as any).message || ""; @@ -1802,119 +1762,78 @@ class AiPlugin extends Plugin { fold: "collapse", cfg: "config", c: "config", - m: "model", + m: "model" }; const subn = aliasMap[subl] || subl; const knownSubs = [ - "config", - "model", - "context", - "collapse", - "telegraph", - "voice", - "prompt", - "chat", - "search", - "image", - "tts", - "audio", - "searchaudio", + "config", "model", "context", "collapse", "telegraph", "voice", "prompt", + "chat", "search", "image", "tts", "audio", "searchaudio", "help", "timeout", "preview", "maxtokens" ]; const isUnknownBareQuery = !!subn && !knownSubs.includes(subn); try { - const preflight = async ( - kind: keyof Models - ): Promise<{ - m: { provider: string; model: string }; - p: Provider; - compat: Compat; - } | null> => { + const preflight = async (kind: keyof Models): Promise<{ m: { provider: string; model: string }; p: Provider; compat: Compat } | null> => { const m = pick(kind); - if (!m) { - await msg.edit({ - text: `❌ 未设置 ${kind} 模型`, - parseMode: "html", - }); - return null; - } + if (!m) { await msg.edit({ text: `❌ 未设置 ${kind} 模型`, parseMode: "html" }); return null; } const p = providerOf(m.provider); - if (!p) { - await msg.edit({ text: "❌ 服务商未配置", parseMode: "html" }); - return null; - } - if (!p.apiKey) { - await msg.edit({ - text: "❌ 未提供令牌,请先配置 API Key(ai config add/update)", - parseMode: "html", - }); - return null; - } + if (!p) { await msg.edit({ text: "❌ 服务商未配置", parseMode: "html" }); return null; } + if (!p.apiKey) { await msg.edit({ text: "❌ 未提供令牌,请先配置 API Key(ai config add/update)", parseMode: "html" }); return null; } const compat = await resolveCompat(m.provider, m.model, p); return { m, p, compat }; }; + /* ---------- 帮助命令 ---------- */ + if (subn === "help" || subn === "h" || subn === "?") { + await sendLong(msg, help_text); + return; + } + /* ---------- Prompt 预设管理 ---------- */ if (subn === "prompt") { const a0 = (args[0] || "").toLowerCase(); if (a0 === "set") { - const promptContent = args.slice(1).join(" ").trim(); + // 支持多行 Prompt:从原始文本中提取 "prompt set" 后的全部内容 + const fullText = (msg as any).text || (msg as any).message || ""; + // 匹配 "-ai prompt set " 或 ".ai prompt set " 等前缀,忽略大小写 + const promptSetMatch = fullText.match(/^[.\-\/!]ai\s+prompt\s+set\s+/i); + let promptContent = ""; + if (promptSetMatch) { + promptContent = fullText.slice(promptSetMatch[0].length).trim(); + } else { + // 回退到旧逻辑(理论上不会走到这里) + promptContent = args.slice(1).join(" ").trim(); + } if (!promptContent) { - await msg.edit({ - text: "❌ 请提供预设Prompt内容", - parseMode: "html", - }); + await msg.edit({ text: "❌ 请提供预设Prompt内容", parseMode: "html" }); return; } Store.data.presetPrompt = promptContent; await Store.writeSoon(); - await msg.edit({ - text: `✅ 已设置全局Prompt预设\n\n
${html( - promptContent - )}
`, - parseMode: "html", - }); + await msg.edit({ text: `✅ 已设置全局Prompt预设\n\n
${html(promptContent)}
`, parseMode: "html" }); return; } if (a0 === "clear") { Store.data.presetPrompt = ""; await Store.writeSoon(); - await msg.edit({ - text: "✅ 已清除全局Prompt预设", - parseMode: "html", - }); + await msg.edit({ text: "✅ 已清除全局Prompt预设", parseMode: "html" }); return; } if (a0 === "show") { const currentPrompt = Store.data.presetPrompt || ""; if (!currentPrompt) { - await msg.edit({ - text: "📝 当前未设置全局Prompt预设", - parseMode: "html", - }); + await msg.edit({ text: "📝 当前未设置全局Prompt预设", parseMode: "html" }); return; } - await sendLong( - msg, - `📝 当前全局Prompt预设\n\n
${html( - currentPrompt - )}
` - ); + await sendLong(msg, `📝 当前全局Prompt预设\n\n
${html(currentPrompt)}
`); return; } - await msg.edit({ - text: "❌ 未知 prompt 子命令\n支持: set|clear|show", - parseMode: "html", - }); + await msg.edit({ text: "❌ 未知 prompt 子命令\n支持: set|clear|show", parseMode: "html" }); return; } /* ---------- 配置管理 ---------- */ if (subn === "config") { - if ((msg as any).isGroup || (msg as any).isChannel) { - await msg.edit({ - text: "❌ 为保护用户隐私,禁止在公共对话环境使用ai config所有子命令", - parseMode: "html", - }); + if (isGroupOrChannel(msg)) { + await msg.edit({ text: "❌ 为保护用户隐私,禁止在公共对话环境使用ai config所有子命令", parseMode: "html" }); return; } const a0 = (args[0] || "").toLowerCase(); @@ -1923,33 +1842,17 @@ class AiPlugin extends Plugin { const flags = [ `• 上下文: ${Store.data.contextEnabled ? "开启" : "关闭"}`, `• 折叠: ${Store.data.collapse ? "开启" : "关闭"}`, - `• Telegraph: ${Store.data.telegraph.enabled ? "开启" : "关闭"}${ - Store.data.telegraph.enabled && Store.data.telegraph.limit - ? `(阈值 ${Store.data.telegraph.limit})` - : "" - }`, - `• Prompt预设: ${ - Store.data.presetPrompt ? "✅ 已设置" : "❌ 未设置" - }`, + `• Telegraph: ${Store.data.telegraph.enabled ? "开启" : "关闭"}${Store.data.telegraph.enabled && Store.data.telegraph.limit ? `(阈值 ${Store.data.telegraph.limit})` : ""}`, + `• Prompt预设: ${Store.data.presetPrompt ? "✅ 已设置" : "❌ 未设置"}`, + ].join("\n"); - const provList = - Object.entries(Store.data.providers) - .map(([n, v]) => { - const display = shortenUrlForDisplay(v.baseUrl); - return `• ${html(n)} - key:${ - v.apiKey ? "✅" : "❌" - } base:${html(display)}`; - }) - .join("\n") || "(空)"; - const txt = `⚙️ AI 配置概览\n\n功能模型\nchat: ${ - html(cur.chat) || "(未设)" - }\nsearch: ${ - html(cur.search) || "(未设)" - }\nimage: ${ - html(cur.image) || "(未设)" - }\ntts: ${ - html(cur.tts) || "(未设)" - }\n\n功能开关\n${flags}\n\n服务商\n${provList}`; + const provList = Object.entries(Store.data.providers) + .map(([n, v]) => { + const display = shortenUrlForDisplay(v.baseUrl); + return `• ${html(n)} - key:${v.apiKey ? "✅" : "❌"} base:${html(display)}`; + }) + .join("\n") || "(空)"; + const txt = `⚙️ AI 配置概览\n\n功能模型\nchat: ${html(cur.chat) || "(未设)"}\nsearch: ${html(cur.search) || "(未设)"}\nimage: ${html(cur.image) || "(未设)"}\ntts: ${html(cur.tts) || "(未设)"}\n\n功能开关\n${flags}\n\n服务商\n${provList}`; await sendLong(msg, txt); return; } @@ -1962,31 +1865,19 @@ class AiPlugin extends Plugin { try { const u = new URL(baseUrl); if (u.protocol !== "http:" && u.protocol !== "https:") { - await msg.edit({ - text: "❌ baseUrl 无效,请使用 http/https 协议", - parseMode: "html", - }); + await msg.edit({ text: "❌ baseUrl 无效,请使用 http/https 协议", parseMode: "html" }); return; } } catch { - await msg.edit({ - text: "❌ baseUrl 无效,请检查是否为合法 URL", - parseMode: "html", - }); + await msg.edit({ text: "❌ baseUrl 无效,请检查是否为合法 URL", parseMode: "html" }); return; } - Store.data.providers[name] = { - apiKey: key, - baseUrl: trimBase(baseUrl.trim()), - }; + Store.data.providers[name] = { apiKey: key, baseUrl: trimBase(baseUrl.trim()) }; if (Store.data.modelCompat) delete Store.data.modelCompat[name]; compatResolving.delete(name); await Store.writeSoon(); - await refreshModelCatalog(true).catch(() => {}); - await msg.edit({ - text: `✅ 已添加 ${html(name)}`, - parseMode: "html", - }); + debouncedRefreshModelCatalog(); + await msg.edit({ text: `✅ 已添加 ${html(name)}`, parseMode: "html" }); return; } if (a0 === "update") { @@ -2008,56 +1899,36 @@ class AiPlugin extends Plugin { try { const u = new URL(value); if (u.protocol !== "http:" && u.protocol !== "https:") { - await msg.edit({ - text: "❌ baseUrl 无效,请使用 http/https 协议", - parseMode: "html", - }); + await msg.edit({ text: "❌ baseUrl 无效,请使用 http/https 协议", parseMode: "html" }); return; } } catch { - await msg.edit({ - text: "❌ baseUrl 无效,请检查是否为合法 URL", - parseMode: "html", - }); + await msg.edit({ text: "❌ baseUrl 无效,请检查是否为合法 URL", parseMode: "html" }); return; } p.baseUrl = trimBase(value.trim()); delete (p as any).compatauth; } else { - await msg.edit({ - text: "❌ 字段仅支持 apikey|baseurl", - parseMode: "html", - }); + await msg.edit({ text: "❌ 字段仅支持 apikey|baseurl", parseMode: "html" }); return; } if (Store.data.modelCompat) delete Store.data.modelCompat[name]; compatResolving.delete(name); await Store.writeSoon(); - await refreshModelCatalog(true).catch(() => {}); - await msg.edit({ - text: `✅ 已更新 ${html(name)}${html( - field - )}`, - parseMode: "html", - }); + debouncedRefreshModelCatalog(); + await msg.edit({ text: `✅ 已更新 ${html(name)}${html(field)}`, parseMode: "html" }); return; } if (a0 === "remove") { const target = (args[1] || "").toLowerCase(); if (!target) { - await msg.edit({ - text: "❌ 请输入服务商名称或 all", - parseMode: "html", - }); + await msg.edit({ text: "❌ 请输入服务商名称或 all", parseMode: "html" }); return; } if (target === "all") { Store.data.providers = {}; Store.data.modelCompat = {}; - Store.data.modelCatalog = { - map: {}, - updatedAt: undefined, - } as any; + Store.data.modelCatalog = { map: {}, updatedAt: undefined } as any; compatResolving.clear(); } else { if (!Store.data.providers[target]) { @@ -2066,32 +1937,24 @@ class AiPlugin extends Plugin { } delete Store.data.providers[target]; if (Store.data.modelCompat) delete Store.data.modelCompat[target]; - const kinds: (keyof Models)[] = [ - "chat", - "search", - "image", - "tts", - ]; + const kinds: (keyof Models)[] = ["chat", "search", "image", "tts"]; for (const k of kinds) { const v = Store.data.models[k]; if (v && v.startsWith(target + " ")) Store.data.models[k] = ""; } } await Store.writeSoon(); - await refreshModelCatalog(true).catch(() => {}); + debouncedRefreshModelCatalog(); await msg.edit({ text: "✅ 已删除", parseMode: "html" }); return; } if (a0 === "list") { - const list = - Object.entries(Store.data.providers) - .map(([n, v]) => { - const display = shortenUrlForDisplay(v.baseUrl); - return `• ${html(n)} - key:${ - v.apiKey ? "✅" : "❌" - } base:${html(display)}`; - }) - .join("\n") || "(空)"; + const list = Object.entries(Store.data.providers) + .map(([n, v]) => { + const display = shortenUrlForDisplay(v.baseUrl); + return `• ${html(n)} - key:${v.apiKey ? "✅" : "❌"} base:${html(display)}`; + }) + .join("\n") || "(空)"; await sendLong(msg, `📦 已配置服务商\n\n${list}`); return; } @@ -2108,45 +1971,22 @@ class AiPlugin extends Plugin { const res = await listModelsByAnyCompat(p); models = res.models; selected = res.compat; - } catch {} + } catch { } if (!models.length || !selected) { - await msg.edit({ - text: "❌ 该服务商的权鉴方式未使用OpenAI、Google Gemini、Claude的标准接口,不做兼容。", - parseMode: "html", - }); + await msg.edit({ text: "❌ 该服务商的权鉴方式未使用OpenAI、Google Gemini、Claude的标准接口,不做兼容。", parseMode: "html" }); return; } - const buckets = { - chat: [] as string[], - search: [] as string[], - image: [] as string[], - tts: [] as string[], - }; + const buckets = { chat: [] as string[], search: [] as string[], image: [] as string[], tts: [] as string[] }; for (const m of models) { const ml = String(m).toLowerCase(); if (/image|dall|sd|gpt-image/.test(ml)) buckets.image.push(m); - else if (/tts|voice|audio\.speech|gpt-4o.*-tts|\b-tts\b/.test(ml)) - buckets.tts.push(m); + else if (/tts|voice|audio\.speech|gpt-4o.*-tts|\b-tts\b/.test(ml)) buckets.tts.push(m); else { buckets.chat.push(m); buckets.search.push(m); } } - const txt = `🧾 ${html( - name! - )} 可用模型\n\nchat/search:\n${ - buckets.chat.length - ? buckets.chat.map((x) => "• " + html(x)).join("\n") - : "(空)" - }\n\nimage:\n${ - buckets.image.length - ? buckets.image.map((x) => "• " + html(x)).join("\n") - : "(空)" - }\n\ntts:\n${ - buckets.tts.length - ? buckets.tts.map((x) => "• " + html(x)).join("\n") - : "(空)" - }`; + const txt = `🧾 ${html(name!)} 可用模型\n\nchat/search:\n${buckets.chat.length ? buckets.chat.map(x => "• " + html(x)).join("\n") : "(空)"}\n\nimage:\n${buckets.image.length ? buckets.image.map(x => "• " + html(x)).join("\n") : "(空)"}\n\ntts:\n${buckets.tts.length ? buckets.tts.map(x => "• " + html(x)).join("\n") : "(空)"}`; await sendLong(msg, txt); return; } @@ -2159,32 +1999,20 @@ class AiPlugin extends Plugin { const a0 = (args[0] || "").toLowerCase(); if (a0 === "list") { const cur = Store.data.models; - const txt = `⚙️ 当前模型配置\n\nchat: ${ - html(cur.chat) || "(未设)" - }\nsearch: ${ - html(cur.search) || "(未设)" - }\nimage: ${ - html(cur.image) || "(未设)" - }\ntts: ${html(cur.tts) || "(未设)"}`; + const txt = `⚙️ 当前模型配置\n\nchat: ${html(cur.chat) || "(未设)"}\nsearch: ${html(cur.search) || "(未设)"}\nimage: ${html(cur.image) || "(未设)"}\ntts: ${html(cur.tts) || "(未设)"}`; await sendLong(msg, txt); return; } if (a0 === "default") { Store.data.models = { chat: "", search: "", image: "", tts: "" }; await Store.writeSoon(); - await msg.edit({ - text: "✅ 已清空所有功能模型设置", - parseMode: "html", - }); + await msg.edit({ text: "✅ 已清空所有功能模型设置", parseMode: "html" }); return; } if (a0 === "auto") { const entries = Object.entries(Store.data.providers); if (!entries.length) { - await msg.edit({ - text: "❌ 请先使用 ai config add 添加服务商", - parseMode: "html", - }); + await msg.edit({ text: "❌ 请先使用 ai config add 添加服务商", parseMode: "html" }); return; } const modelsBy: Record = {}; @@ -2200,29 +2028,13 @@ class AiPlugin extends Plugin { modelsBy[n] = []; } } - const bucketsBy: Record< - string, - { - chat: string[]; - search: string[]; - image: string[]; - tts: string[]; - } - > = {}; + const bucketsBy: Record = {}; for (const [n, list] of Object.entries(modelsBy)) { - const buckets = { - chat: [] as string[], - search: [] as string[], - image: [] as string[], - tts: [] as string[], - }; + const buckets = { chat: [] as string[], search: [] as string[], image: [] as string[], tts: [] as string[] }; for (const m of list) { const ml = String(m).toLowerCase(); if (/image|dall|sd|gpt-image/.test(ml)) buckets.image.push(m); - else if ( - /tts|voice|audio\.speech|gpt-4o.*-tts|\b-tts\b/.test(ml) - ) - buckets.tts.push(m); + else if (/tts|voice|audio\.speech|gpt-4o.*-tts|\b-tts\b/.test(ml)) buckets.tts.push(m); else { buckets.chat.push(m); buckets.search.push(m); @@ -2230,117 +2042,31 @@ class AiPlugin extends Plugin { } bucketsBy[n] = buckets; } - const orders: Array = [ - "openai", - "gemini", - "claude", - "other", - ]; + const orders: Array = ["openai", "gemini", "claude", "other"]; const modelFamilyOf = (m: string): Compat | "other" => { - const s = String(m).toLowerCase(); - if ( - /(gpt-|dall-e|gpt-image|tts-1|gpt-4o|\bo[134](?:-|\b))/.test(s) - ) - return "openai"; + const s = m.toLowerCase(); + if (/(gpt-|dall-e|gpt-image|tts-1|gpt-4o|\bo[134](?:-|\b))/.test(s)) return "openai"; if (/gemini/.test(s)) return "gemini"; if (/claude/.test(s)) return "claude"; return "other"; }; - const isStable = (m: string) => { - const s = String(m).toLowerCase(); - return !/(preview|experimental|beta|dev|test|sandbox|staging)/.test( - s - ); - }; + const isStable = (m: string) => !/(preview|experimental|beta|dev|test|sandbox|staging)/i.test(m); const labelWeight = (s: string) => { + const l = s.toLowerCase(); let w = 0; - if (/\bultra\b/.test(s)) w += 0.09; - if (/\bpro\b/.test(s)) w += 0.08; - if (/\bopus\b/.test(s)) w += 0.08; - if (/\bsonnet\b/.test(s)) w += 0.07; - if (/\bhaiku\b/.test(s)) w += 0.03; - if (/\bflash\b/.test(s)) w += 0.06; - if (/\bnano\b|\blite\b|\bmini\b/.test(s)) w += 0.02; + if (/ultra/.test(l)) w += 0.09; if (/\bpro\b/.test(l)) w += 0.08; if (/opus/.test(l)) w += 0.08; + if (/sonnet/.test(l)) w += 0.07; if (/flash/.test(l)) w += 0.06; if (/haiku/.test(l)) w += 0.03; + if (/nano|lite|mini/.test(l)) w += 0.02; return w; }; - const popularPatterns: Record = { - openai: [ - /\bgpt-4o\b/i, - /\bgpt-4o-mini\b/i, - /\bgpt-4\.1\b/i, - /\bgpt-4\.1-mini\b/i, - /\bgpt-4-turbo\b/i, - /\bgpt-4\b/i, - /\bgpt-3\.5-turbo\b/i, - /\bgpt-image-1\b/i, - /\btts-1\b/i, - /\btts-1-hd\b/i, - /\bo3\b/i, - /\bo4-mini\b/i, - /\bo3-mini\b/i, - /\bo1\b/i, - ], - claude: [ - /\bclaude-3\.7-sonnet\b/i, - /\bclaude-3-7-sonnet\b/i, - /\bclaude-3\.5-sonnet\b/i, - /\bclaude-3-5-sonnet\b/i, - /\bclaude-3\.5-haiku\b/i, - /\bclaude-3-5-haiku\b/i, - /\bclaude-3-opus\b/i, - /\bclaude-3-sonnet\b/i, - /\bclaude-3-haiku\b/i, - /\bclaude-2\.1\b/i, - /\bclaude-2\b/i, - ], - gemini: [ - /\bgemini-2\.5-pro\b/i, - /\bgemini-2\.5-flash\b/i, - /\bgemini-2\.5-flash-lite\b/i, - /\bgemini-2\.0-flash\b/i, - /\bgemini-1\.5-pro\b/i, - /\bgemini-1\.5-flash\b/i, - /\bgemini-1\.5-flash-8b\b/i, - /\bgemini-1\.0-pro\b/i, - /\bgemini-1\.0-pro-vision\b/i, - ], - other: [ - /\bdeepseek-chat\b/i, - /\bdeepseek-reasoner\b/i, - /\bdeepseek-v3\b/i, - /\bdeepseek-v3\.1\b/i, - /\bdeepseek-r1\b/i, - /\bgrok-2\b/i, - /\bgrok-2-1212\b/i, - /\bgrok-2-vision-1212\b/i, - /\bgrok-1\b/i, - /\bllama-3\.1-405b-instruct\b/i, - /\bllama-3\.1-70b-instruct\b/i, - /\bllama-3-70b-instruct\b/i, - /\bllama-3\.1-8b-instruct\b/i, - /\bllama-3-8b-instruct\b/i, - /\bllama-3\.3-70b-instruct\b/i, - /\bmistral-large\b/i, - /\bmistral-large-2\b/i, - /\bmixtral-8x22b-instruct\b/i, - /\bmixtral-8x7b-instruct\b/i, - /\bqwen2\.5-72b-instruct\b/i, - /\bqwen2-72b-instruct\b/i, - /\bqwen2\.5-32b-instruct\b/i, - /\bqwen2\.5-7b-instruct\b/i, - /\bqwen2-7b-instruct\b/i, - /\bcommand-r\+\b/i, - /\bcommand-r-plus\b/i, - /\bcommand-r\b/i, - ], - }; - const isPopularByFamily = (m: string, family: Compat | "other") => { - const s = String(m).toLowerCase(); - const pats = popularPatterns[family] || []; - return pats.some((re) => re.test(s)); + const popularPatterns: Record = { + openai: /gpt-4o|gpt-4\.?1|gpt-4-turbo|gpt-4|gpt-3\.5|gpt-image|tts-1|o[134]-?mini?/i, + claude: /claude-3\.?[57]-sonnet|claude-3-opus|claude-3-sonnet|claude-3-haiku|claude-2/i, + gemini: /gemini-2\.5|gemini-2\.0|gemini-1\.5|gemini-1\.0/i, + other: /deepseek|grok|llama-3|mistral|mixtral|qwen2|command-r/i }; - const popularityWeight = (m: string, family: Compat | "other") => - isPopularByFamily(m, family) ? 0.5 : 0; + const isPopularByFamily = (m: string, family: Compat | "other") => popularPatterns[family]?.test(m) ?? false; + const popularityWeight = (m: string, family: Compat | "other") => isPopularByFamily(m, family) ? 0.5 : 0; const versionScore = (m: string, family: Compat | "other") => { const s = String(m).toLowerCase(); const numMatch = s.match(/(\d+(?:\.\d+)?)/); @@ -2349,31 +2075,21 @@ class AiPlugin extends Plugin { if (/tts-1/.test(s)) base = Math.max(base, 1.0); return base + labelWeight(s) + popularityWeight(m, family); }; - const sortCandidates = ( - _kind: "chat" | "search" | "image" | "tts", - family: Compat | "other", - list: string[] - ) => { - const preferred = list.filter((m) => - isPopularByFamily(m, family) - ); + const sortCandidates = (_kind: "chat" | "search" | "image" | "tts", family: Compat | "other", list: string[]) => { + const preferred = list.filter(m => isPopularByFamily(m, family)); const useList = preferred.length ? preferred : list; - const stable = useList.filter((m) => isStable(m)); - const unstable = useList.filter((m) => !isStable(m)); - const cmp = (a: string, b: string) => - versionScore(b, family) - versionScore(a, family); + const stable = useList.filter(m => isStable(m)); + const unstable = useList.filter(m => !isStable(m)); + const cmp = (a: string, b: string) => versionScore(b, family) - versionScore(a, family); stable.sort(cmp); unstable.sort(cmp); return [...stable, ...unstable]; }; - const pickAcrossKind = ( - kind: "chat" | "search" | "image" | "tts", - preferredProvider?: string - ) => { + const pickAcrossKind = (kind: "chat" | "search" | "image" | "tts", preferredProvider?: string) => { const providerOrder = (() => { const names = entries.map(([n]) => n); if (preferredProvider && names.includes(preferredProvider)) { - const rest = names.filter((n) => n !== preferredProvider); + const rest = names.filter(n => n !== preferredProvider); return [preferredProvider, ...rest]; } return names; @@ -2382,9 +2098,7 @@ class AiPlugin extends Plugin { for (const n of providerOrder) { const bucket = bucketsBy[n]?.[kind] || []; if (!bucket.length) continue; - const candidates = bucket.filter( - (m) => modelFamilyOf(m) === fam - ); + const candidates = bucket.filter(m => modelFamilyOf(m) === fam); if (!candidates.length) continue; const sorted = sortCandidates(kind, fam, candidates); const m = sorted[0]; @@ -2404,39 +2118,23 @@ class AiPlugin extends Plugin { const searchPref = pick("search")?.provider || undefined; const imagePref = pick("image")?.provider || undefined; const ttsPref = pick("tts")?.provider || undefined; - const anchorProvider = - chatPref || searchPref || imagePref || ttsPref || undefined; + const anchorProvider = chatPref || searchPref || imagePref || ttsPref || undefined; const chatSel = pickAcrossKind("chat", anchorProvider); const searchSel = pickAcrossKind("search", anchorProvider); const imageSel = pickAcrossKind("image", anchorProvider); const ttsSel = pickAcrossKind("tts", anchorProvider); if (!chatSel) { - await msg.edit({ - text: "❌ 未在任何已配置服务商中找到可用 chat 模型", - parseMode: "html", - }); + await msg.edit({ text: "❌ 未在任何已配置服务商中找到可用 chat 模型", parseMode: "html" }); return; } const prev = { ...Store.data.models }; Store.data.models.chat = `${chatSel.n} ${chatSel.m}`; - Store.data.models.search = searchSel - ? `${searchSel.n} ${searchSel.m}` - : prev.search; - Store.data.models.image = imageSel - ? `${imageSel.n} ${imageSel.m}` - : prev.image; - Store.data.models.tts = ttsSel - ? `${ttsSel.n} ${ttsSel.m}` - : prev.tts; + Store.data.models.search = searchSel ? `${searchSel.n} ${searchSel.m}` : prev.search; + Store.data.models.image = imageSel ? `${imageSel.n} ${imageSel.m}` : prev.image; + Store.data.models.tts = ttsSel ? `${ttsSel.n} ${ttsSel.m}` : prev.tts; await Store.writeSoon(); const cur = Store.data.models; - const detail = `✅ 已智能分配 chat/search/image/tts\n\nchat: ${ - html(cur.chat) || "(未设)" - }\nsearch: ${ - html(cur.search) || "(未设)" - }\nimage: ${ - html(cur.image) || "(未设)" - }\ntts: ${html(cur.tts) || "(未设)"}`; + const detail = `✅ 已智能分配 chat/search/image/tts\n\nchat: ${html(cur.chat) || "(未设)"}\nsearch: ${html(cur.search) || "(未设)"}\nimage: ${html(cur.image) || "(未设)"}\ntts: ${html(cur.tts) || "(未设)"}`; await msg.edit({ text: detail, parseMode: "html" }); return; } @@ -2454,12 +2152,7 @@ class AiPlugin extends Plugin { } Store.data.models[kind] = `${provider} ${model}`; await Store.writeSoon(); - await msg.edit({ - text: `✅ 已设置 ${kind}: ${html( - Store.data.models[kind] - )}`, - parseMode: "html", - }); + await msg.edit({ text: `✅ 已设置 ${kind}: ${html(Store.data.models[kind])}`, parseMode: "html" }); return; } await msg.edit({ text: "❌ 未知 model 子命令", parseMode: "html" }); @@ -2484,26 +2177,20 @@ class AiPlugin extends Plugin { } if (a0 === "show") { const items = histFor(id); - const t = items - .map((x) => `${x.role}: ${html(x.content)}`) - .join("\n"); + const t = items.map(x => `${x.role}: ${html(x.content)}`).join("\n"); await sendLong(msg, t || "(空)"); return; } if (a0 === "del") { + const histItems = Store.data.histories[id] || []; + const count = histItems.length; delete Store.data.histories[id]; if (Store.data.histMeta) delete Store.data.histMeta[id]; await Store.writeSoon(); - await msg.edit({ - text: "✅ 已清空本会话上下文", - parseMode: "html", - }); + await msg.edit({ text: `✅ 已清空本会话上下文(${count} 条记录)`, parseMode: "html" }); return; } - await msg.edit({ - text: "❌ 未知 context 子命令\n支持: on|off|show|del", - parseMode: "html", - }); + await msg.edit({ text: "❌ 未知 context 子命令\n支持: on|off|show|del", parseMode: "html" }); return; } @@ -2512,10 +2199,7 @@ class AiPlugin extends Plugin { const a0 = (args[0] || "").toLowerCase(); Store.data.collapse = a0 === "on"; await Store.writeSoon(); - await msg.edit({ - text: `✅ 消息折叠: ${Store.data.collapse ? "开启" : "关闭"}`, - parseMode: "html", - }); + await msg.edit({ text: `✅ 消息折叠: ${Store.data.collapse ? "开启" : "关闭"}`, parseMode: "html" }); return; } @@ -2538,22 +2222,11 @@ class AiPlugin extends Plugin { const n = parseInt(args[1] || "0"); Store.data.telegraph.limit = isFinite(n) ? n : 0; await Store.writeSoon(); - await msg.edit({ - text: `✅ 阈值: ${Store.data.telegraph.limit}`, - parseMode: "html", - }); + await msg.edit({ text: `✅ 阈值: ${Store.data.telegraph.limit}`, parseMode: "html" }); return; } if (a0 === "list") { - const list = - Store.data.telegraph.posts - .map( - (p, i) => - `${i + 1}. ${html(p.title)} ${ - p.createdAt - }` - ) - .join("\n") || "(空)"; + const list = Store.data.telegraph.posts.map((p, i) => `${i + 1}. ${html(p.title)} ${p.createdAt}`).join("\n") || "(空)"; await sendLong(msg, `🧾 Telegraph 列表\n\n${list}`); return; } @@ -2568,30 +2241,20 @@ class AiPlugin extends Plugin { await msg.edit({ text: "✅ 操作完成", parseMode: "html" }); return; } - await msg.edit({ - text: "❌ 未知 telegraph 子命令", - parseMode: "html", - }); + await msg.edit({ text: "❌ 未知 telegraph 子命令", parseMode: "html" }); return; } /* ---------- 音色管理 ---------- */ if (subn === "voice") { const a0 = (args[0] || "").toLowerCase(); - if (!Store.data.voices) - Store.data.voices = { gemini: "Kore", openai: "alloy" }; + if (!Store.data.voices) Store.data.voices = { gemini: "Kore", openai: "alloy" }; if (a0 === "list") { - const geminiList = GEMINI_VOICES.map( - (v, i) => `${i + 1}. ${v}` - ).join("\n"); - const openaiList = OPENAI_VOICES.map( - (v, i) => `${i + 1}. ${v}` - ).join("\n"); + const geminiList = GEMINI_VOICES.map((v, i) => `${i + 1}. ${v}`).join("\n"); + const openaiList = OPENAI_VOICES.map((v, i) => `${i + 1}. ${v}`).join("\n"); const header = `🎤 可用音色列表\n\n当前配置:\nGemini: ${Store.data.voices.gemini}\nOpenAI: ${Store.data.voices.openai}\n\n`; const collapsedContent = `Gemini (${GEMINI_VOICES.length}种):\n${geminiList}\n\nOpenAI (${OPENAI_VOICES.length}种):\n${openaiList}`; - const txt = - header + - `
${collapsedContent}
`; + const txt = header + `
${collapsedContent}
`; await sendLong(msg, txt); return; } @@ -2603,84 +2266,174 @@ class AiPlugin extends Plugin { if (a0 === "gemini") { const voiceName = args[1]; if (!voiceName) { - await msg.edit({ - text: `❌ 请指定音色名称\n当前: ${Store.data.voices.gemini}`, - parseMode: "html", - }); + await msg.edit({ text: `❌ 请指定音色名称\n当前: ${Store.data.voices.gemini}`, parseMode: "html" }); return; } if (!GEMINI_VOICES.includes(voiceName as any)) { - await msg.edit({ - text: `❌ 未知音色: ${html( - voiceName - )}\n使用 ai voice list 查看可用音色`, - parseMode: "html", - }); + await msg.edit({ text: `❌ 未知音色: ${html(voiceName)}\n使用 ai voice list 查看可用音色`, parseMode: "html" }); return; } Store.data.voices.gemini = voiceName; await Store.writeSoon(); - await msg.edit({ - text: `✅ 已设置 Gemini 音色: ${html(voiceName)}`, - parseMode: "html", - }); + await msg.edit({ text: `✅ 已设置 Gemini 音色: ${html(voiceName)}`, parseMode: "html" }); return; } if (a0 === "openai") { const voiceName = args[1]; if (!voiceName) { - await msg.edit({ - text: `❌ 请指定音色名称\n当前: ${Store.data.voices.openai}`, - parseMode: "html", - }); + await msg.edit({ text: `❌ 请指定音色名称\n当前: ${Store.data.voices.openai}`, parseMode: "html" }); return; } if (!OPENAI_VOICES.includes(voiceName as any)) { - await msg.edit({ - text: `❌ 未知音色: ${html( - voiceName - )}\n使用 ai voice list 查看可用音色`, - parseMode: "html", - }); + await msg.edit({ text: `❌ 未知音色: ${html(voiceName)}\n使用 ai voice list 查看可用音色`, parseMode: "html" }); return; } Store.data.voices.openai = voiceName; await Store.writeSoon(); - await msg.edit({ - text: `✅ 已设置 OpenAI 音色: ${html(voiceName)}`, - parseMode: "html", - }); + await msg.edit({ text: `✅ 已设置 OpenAI 音色: ${html(voiceName)}`, parseMode: "html" }); + return; + } + await msg.edit({ text: "❌ 未知 voice 子命令\n支持: list|show|gemini <音色>|openai <音色>", parseMode: "html" }); + return; + } + + /* ---------- 超时设置 ---------- */ + if (subn === "timeout") { + const a0 = (args[0] || "").toLowerCase(); + if (a0 === "show" || !a0) { + const current = Store.data.timeout || DEFAULT_TIMEOUT_MS; + await msg.edit({ text: `⏱️ 当前超时时间: ${current / 1000}秒`, parseMode: "html" }); + return; + } + if (a0 === "set") { + const val = args[1]; + if (!val) { + await msg.edit({ text: "❌ 请指定超时时间(秒)\n例如: ai timeout set 180 设置为180秒", parseMode: "html" }); + return; + } + const sec = parseInt(val); + if (!isFinite(sec) || sec < 10 || sec > 600) { + await msg.edit({ text: "❌ 超时时间必须在 10-600 秒之间(最多10分钟)", parseMode: "html" }); + return; + } + Store.data.timeout = sec * 1000; + await Store.writeSoon(); + await msg.edit({ text: `✅ 已设置超时时间: ${sec}秒`, parseMode: "html" }); + return; + } + if (a0 === "reset") { + Store.data.timeout = DEFAULT_TIMEOUT_MS; + await Store.writeSoon(); + await msg.edit({ text: `✅ 已重置超时时间为默认值: ${DEFAULT_TIMEOUT_MS / 1000}秒`, parseMode: "html" }); return; } - await msg.edit({ - text: "❌ 未知 voice 子命令\n支持: list|show|gemini <音色>|openai <音色>", - parseMode: "html", - }); + await msg.edit({ text: "❌ 未知 timeout 子命令\n支持: show|set <秒>|reset", parseMode: "html" }); + return; + } + + /* ---------- 最大输出 Token 设置 ---------- */ + if (subn === "maxtokens" || subn === "tokens" || subn === "maxtoken") { + const a0 = (args[0] || "").toLowerCase(); + if (a0 === "show" || !a0) { + const current = Store.data.maxTokens || DEFAULT_MAX_TOKENS; + const approxChars = Math.floor(current / 2); // 大约1 token = 0.5个中文字 + await msg.edit({ text: `📝 当前最大输出 Token: ${current}\n约等于 ${approxChars} 个中文字\n\n💡 生成超长文本时建议同时增加超时时间`, parseMode: "html" }); + return; + } + if (a0 === "set") { + const val = args[1]; + if (!val) { + await msg.edit({ text: "❌ 请指定最大 token 数\n例如: ai maxtokens set 32768 设置为32768", parseMode: "html" }); + return; + } + const num = parseInt(val); + if (!isFinite(num) || num < 100 || num > 128000) { + await msg.edit({ text: "❌ Token 数必须在 100-128000 之间", parseMode: "html" }); + return; + } + Store.data.maxTokens = num; + await Store.writeSoon(); + const approxChars = Math.floor(num / 2); + await msg.edit({ text: `✅ 已设置最大输出 Token: ${num}\n约等于 ${approxChars} 个中文字\n\n💡 建议同时设置超时: ai timeout set 300`, parseMode: "html" }); + return; + } + if (a0 === "reset") { + Store.data.maxTokens = DEFAULT_MAX_TOKENS; + await Store.writeSoon(); + await msg.edit({ text: `✅ 已重置最大输出 Token 为默认值: ${DEFAULT_MAX_TOKENS}`, parseMode: "html" }); + return; + } + await msg.edit({ text: "❌ 未知 maxtokens 子命令\n支持: show|set <数量>|reset", parseMode: "html" }); + return; + } + + /* ---------- 链接预览开关 ---------- */ + if (subn === "preview") { + const a0 = (args[0] || "").toLowerCase(); + if (a0 === "on") { + Store.data.linkPreview = true; + await Store.writeSoon(); + await msg.edit({ text: "✅ 已开启链接预览", parseMode: "html" }); + return; + } + if (a0 === "off") { + Store.data.linkPreview = false; + await Store.writeSoon(); + await msg.edit({ text: "✅ 已关闭链接预览", parseMode: "html" }); + return; + } + const current = Store.data.linkPreview !== false; + await msg.edit({ text: `🔗 链接预览: ${current ? "开启" : "关闭"}\n\n用法: ai preview on|off`, parseMode: "html" }); return; } /* ---------- 对话 / 搜索 ---------- */ - if ( - subn === "chat" || - subn === "search" || - !subn || - isUnknownBareQuery - ) { + if (subn === "chat" || subn === "search" || !subn || isUnknownBareQuery) { const replyMsg = await msg.getReplyMessage(); const isSearch = subn === "search"; - const plain = ( - (isUnknownBareQuery ? [sub, ...args] : args).join(" ") || "" - ).trim(); - const repliedText = extractText(replyMsg).trim(); - const q = (plain || repliedText).trim(); - const hasImage = !!(replyMsg && (replyMsg as any).media); - if (!q && !hasImage) { - await msg.edit({ - text: "❌ 请输入内容或回复一条消息", - parseMode: "html", - }); + const plain = (((isUnknownBareQuery ? [sub, ...args] : args).join(" ") || "").trim()); + + // 仿照 temp/ai (10).ts 的逻辑处理上下文 + let question = plain; + let context = ""; + + // 尝试智能获取媒体(支持图片、GIF、Sticker等) + // 如果有回复消息,优先用回复消息的媒体;否则尝试当前消息 + const mediaTarget = replyMsg && (replyMsg as any).media ? replyMsg : ((msg as any).media ? msg : null); + const mediaData = mediaTarget ? await downloadMessageMediaAsData(mediaTarget) : null; + const hasImage = !!mediaData; + + if (replyMsg) { + // 优先使用被引用的部分,如果没有则使用整条消息 + context = extractQuoteOrReplyText(msg, replyMsg).trim(); + // 如果回复的是图片消息但没有文字内容,补充说明 + if (!context && hasImage && mediaTarget === replyMsg) { + context = "[用户引用了一张图片]"; + } + } + + // 如果用户没有输入问题(只发了 .ai),则直接把引用消息当作问题 + if (!question && context) { + question = context; + context = ""; // 避免重复,既然当作问题了就不用作上下文了 + } + + if (!question && !hasImage) { + await msg.edit({ text: "❌ 请输入内容或回复一条消息", parseMode: "html" }); return; } + + // 构建最终发给 AI 的内容 (Prompt) + // 格式: + // 引用消息: + // [内容] + // + // 用户消息: + // [内容] + let q = question; + if (context) { + q = `引用消息:\n${context}\n\n用户消息:\n${question}`; + } await msg.edit({ text: "🔄 处理中...", parseMode: "html" }); const pre = await preflight(isSearch ? "search" : "chat"); if (!pre) return; @@ -2688,35 +2441,14 @@ class AiPlugin extends Plugin { let content = ""; let usedModel = m.model; - if (hasImage) { + if (hasImage && mediaData) { try { - const raw = await msg.client?.downloadMedia(replyMsg as any); - const buf: Buffer | undefined = Buffer.isBuffer(raw) - ? (raw as Buffer) - : raw != null - ? Buffer.from(String(raw)) - : undefined; - if (!buf || !buf.length) { - await msg.edit({ - text: "❌ 无法下载被回复的媒体", - parseMode: "html", - }); - return; - } - const b64 = buf.toString("base64"); + const b64 = mediaData.buffer.toString("base64"); const processedPrompt = applyPresetPrompt(q || "描述这张图片"); - content = await chatVision( - p, - compat, - m.model, - b64, - processedPrompt - ); + // 传入 mime 以支持不同格式(虽然目前转为 PNG 或原格式) + content = await chatVision(p, compat, m.model, b64, processedPrompt, mediaData.mime); } catch (e: any) { - await msg.edit({ - text: `❌ 处理图片失败:${html(mapError(e, "vision"))}`, - parseMode: "html", - }); + await msg.edit({ text: `❌ 处理图片失败:${html(mapError(e, "vision"))}`, parseMode: "html" }); return; } } else { @@ -2725,52 +2457,79 @@ class AiPlugin extends Plugin { usedModel = res.model; } + // 处理 AI 响应:提取内嵌图片,清理思考标签 + const processed = processAIResponse(content); + const replyToId = replyMsg?.id || 0; const footTxt = footer(usedModel, isSearch ? "with Search" : ""); + + // 如果响应中包含内嵌图片,先发送图片 + if (processed.images.length > 0) { + for (const img of processed.images) { + try { + const buf = Buffer.from(img.data, "base64"); + const caption = img.alt ? `🖼️ ${html(img.alt)}` : `🖼️ AI 生成的图片`; + await sendImageFile(msg, buf, caption + footTxt, replyToId, img.mime); + } catch { + // 图片发送失败,继续处理 + } + } + // 如果只有图片没有其他文字内容,删除原消息并返回 + const textContent = processed.text.replace(/\[图片\]/g, "").replace(/\[图片数据\]/g, "").trim(); + if (!textContent || textContent.length < 10) { + try { await msg.delete({ revoke: true }); } catch { /* 忽略删除失败 */ } + return; + } + // 如果还有文字内容,继续处理 + content = processed.text; + } else { + // 即使没有图片也要清理思考标签 + content = processed.text; + } + const full = formatQA(q || "(图片)", content); - const replyToId = replyMsg?.id || 0; - if ( - Store.data.telegraph.enabled && - Store.data.telegraph.limit > 0 && - full.length > Store.data.telegraph.limit - ) { - const url = await createTGPage("TeleBox AI", content); - if (url) { - Store.data.telegraph.posts.unshift({ - title: (q || "图片").slice(0, 30) || "AI", - url, - createdAt: nowISO(), - }); - Store.data.telegraph.posts = Store.data.telegraph.posts.slice( - 0, - 10 - ); + + if (Store.data.telegraph.enabled && Store.data.telegraph.limit > 0 && full.length > Store.data.telegraph.limit) { + const tgContent = `Q: ${q || "(图片)"}\n\nA: ${content}`; + const urls = await createTGPage("TeleBox AI", tgContent); + if (urls.length > 0) { + // 保存历史记录(倒序插入,保持时间顺序) + for (let i = urls.length - 1; i >= 0; i--) { + Store.data.telegraph.posts.unshift({ title: (q || "图片").slice(0, 30) || "AI", url: urls[i], createdAt: nowISO() }); + } + Store.data.telegraph.posts = Store.data.telegraph.posts.slice(0, 10); await Store.writeSoon(); - await sendLongAuto( - msg, - `📰 内容较长,已创建 Telegraph`, - replyToId, - { collapse: Store.data.collapse }, - footTxt - ); - if (replyToId) { - try { - await msg.delete(); - } catch {} + + const links = urls.map((u, i) => { + const num = urls.length > 1 ? (['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][i] || (i + 1)) : ''; + return `🔗 点我阅读内容${num}`; + }).join("\n\n"); + const linkText = `📰 内容较长,Telegraph观感更好喔:\n\n${links}`; + + const tgMsg = `Q:\n${q || "(图片)"}\n\nA:\n${linkText}\n${footTxt}`; + try { await msg.delete({ revoke: true }); } catch { /* 忽略删除失败 */ } + if (msg.client) { + await msg.client.sendMessage(msg.peerId, { + message: tgMsg, + parseMode: "html", + replyTo: replyToId || undefined, + linkPreview: Store.data.linkPreview !== false + }); } return; } } - await sendLongAuto( - msg, - full, - replyToId, - { collapse: Store.data.collapse }, - footTxt - ); - if (replyToId) { - try { - await msg.delete(); - } catch {} + // 发送结果并删除原消息 + try { await msg.delete({ revoke: true }); } catch { /* 忽略删除失败 */ } + const chunks = buildChunks(full, Store.data.collapse, footTxt); + if (msg.client && chunks.length > 0) { + const peer = msg.peerId; + for (const chunk of chunks) { + await msg.client.sendMessage(peer, { + message: chunk, + parseMode: "html", + replyTo: replyToId || undefined + }); + } } return; } @@ -2778,78 +2537,80 @@ class AiPlugin extends Plugin { /* ---------- 生图 ---------- */ if (subn === "image") { const replyMsg = await msg.getReplyMessage(); - const prm = - (args.join(" ") || "").trim() || extractText(replyMsg).trim(); - if (!prm) { + const fullText = (msg as any).text || (msg as any).message || ""; + const imagePromptMatch = fullText.match(/^[.\-\/!]ai\s+(?:image|img|i)\s+([\s\S]*)$/im); + const userInput = (imagePromptMatch ? imagePromptMatch[1].trim() : args.join(" ").trim()); + const replyContent = extractQuoteOrReplyText(msg, replyMsg).trim(); + + // 检查是否有回复的图片(图生图模式) + const mediaTarget = replyMsg && (replyMsg as any).media ? replyMsg : null; + const mediaData = mediaTarget ? await downloadMessageMediaAsData(mediaTarget) : null; + const hasSourceImage = !!mediaData; + + // 结合用户输入和引用内容 + let prm = ""; + if (userInput) { + prm = userInput; + } else if (replyContent && !hasSourceImage) { + prm = replyContent; + } else if (hasSourceImage) { + prm = "请基于这张图片进行创作"; + } + if (!prm && !hasSourceImage) { await msg.edit({ text: "❌ 请输入提示词", parseMode: "html" }); return; } const pre = await preflight("image"); if (!pre) return; const { m, p, compat } = pre; - await msg.edit({ text: "🎨 生成中...", parseMode: "html" }); const replyToId = replyMsg?.id || 0; + + await msg.edit({ text: hasSourceImage ? "🎨 图生图处理中..." : "🎨 生成中...", parseMode: "html" }); + if (compat === "openai") { - const b64 = await imageOpenAI(p, m.model, prm); + // OpenAI 兼容模式:支持图生图 + const sourceImage = hasSourceImage && mediaData ? { + data: mediaData.buffer.toString("base64"), + mime: mediaData.mime + } : undefined; + const b64 = await imageOpenAI(p, m.model, prm, sourceImage); if (!b64) { - await msg.edit({ - text: "❌ 图片生成失败:服务无有效输出", - parseMode: "html", - }); + await msg.edit({ text: "❌ 图片生成失败:服务无有效输出", parseMode: "html" }); return; } const buf = Buffer.from(b64, "base64"); - await sendImageFile( - msg, - buf, - `🖼️ ${html(prm)}` + footer(m.model), - replyToId - ); + const caption = hasSourceImage ? `🖼️ AI 图生图` : `🖼️ AI 生成图片`; + await sendImageFile(msg, buf, caption + footer(m.model), replyToId); await msg.delete(); return; } else if (compat === "gemini") { try { - const { image, text, mime } = await imageGemini(p, m.model, prm); + // 如果有源图片,传入图生图模式 + const sourceImage = hasSourceImage && mediaData ? { + data: mediaData.buffer.toString("base64"), + mime: mediaData.mime + } : undefined; + const { image, text, mime } = await imageGemini(p, m.model, prm, sourceImage); if (image) { - await sendImageFile( - msg, - image, - `🖼️ ${html(prm)}` + footer(m.model), - replyToId, - mime - ); + const caption = hasSourceImage ? `🖼️ AI 图生图` : `🖼️ AI 生成图片`; + await sendImageFile(msg, image, caption + footer(m.model), replyToId, mime); await msg.delete(); return; } if (text) { const textOut = formatQA(prm, text); - await sendLongAuto( - msg, - textOut, - replyToId, - { collapse: Store.data.collapse }, - footer(m.model) - ); + await sendLongAuto(msg, textOut, replyToId, { collapse: Store.data.collapse }, footer(m.model)); await msg.delete(); return; } - await msg.edit({ - text: "❌ 图片生成失败:服务无有效输出", - parseMode: "html", - }); + await msg.edit({ text: "❌ 图片生成失败:服务无有效输出", parseMode: "html" }); return; } catch (e: any) { - await msg.edit({ - text: `❌ 图片生成失败:${html(mapError(e, "image"))}`, - parseMode: "html", - }); + await msg.edit({ text: `❌ 图片生成失败:${html(mapError(e, "image"))}`, parseMode: "html" }); return; } } else { - await msg.edit({ - text: "❌ 当前服务商不支持图片生成功能", - parseMode: "html", - }); + await msg.edit({ text: "❌ 当前服务商不支持图片生成功能", parseMode: "html" }); return; } } @@ -2858,149 +2619,74 @@ class AiPlugin extends Plugin { if (subn === "audio" || subn === "searchaudio") { const replyMsg = await msg.getReplyMessage(); const plain = (args.join(" ") || "").trim(); - const repliedText = extractText(replyMsg).trim(); - const q = (plain || repliedText).trim(); - if (!q) { - await msg.edit({ - text: "❌ 请输入内容或回复一条消息", - parseMode: "html", - }); - return; - } - await msg.edit({ text: "🔄 处理中...", parseMode: "html" }); - const isSearch = subn === "searchaudio"; - const res = await callChat(isSearch ? "search" : "chat", q, msg); - const content = res.content; - const mtts = pick("tts"); - if (!mtts) { - await msg.edit({ text: "❌ 未设置 tts 模型", parseMode: "html" }); - return; - } - const ptts = providerOf(mtts.provider); - if (!ptts) { - await msg.edit({ text: "❌ 服务商未配置", parseMode: "html" }); - return; + + // 仿照 temp/ai (10).ts 的逻辑处理上下文 (Voice版) + let question = plain; + let context = ""; + + if (replyMsg) { + context = extractQuoteOrReplyText(msg, replyMsg).trim(); } - if (!ptts.apiKey) { - await msg.edit({ - text: "❌ 未提供令牌,请先配置 API Key(ai config add/update)", - parseMode: "html", - }); - return; + + if (!question && context) { + question = context; + context = ""; } - const compat = await resolveCompat(mtts.provider, mtts.model, ptts); - if (!Store.data.voices) - Store.data.voices = { gemini: "Kore", openai: "alloy" }; - const voice = - compat === "gemini" - ? Store.data.voices.gemini - : Store.data.voices.openai; - await msg.edit({ text: "🔊 合成中...", parseMode: "html" }); - const replyToId = replyMsg?.id || 0; - if (compat === "openai") { - const audio = await ttsOpenAI(ptts, mtts.model, content, voice); - await sendVoiceWithCaption(msg, audio, "", replyToId); - await msg.delete(); - return; - } else if (compat === "gemini") { - const { audio, mime } = await ttsGemini( - ptts, - mtts.model, - content, - voice - ); - if (audio) { - const { buf: outBuf } = convertPcmL16ToWavIfNeeded(audio, mime); - await sendVoiceWithCaption(msg, outBuf, "", replyToId); - await msg.delete(); - return; - } else { - await msg.edit({ - text: "❌ 语音合成失败:服务无有效输出", - parseMode: "html", - }); - return; - } - } else { - await msg.edit({ - text: "❌ 当前服务商不支持语音合成功能", - parseMode: "html", - }); - return; + + if (!question) { await msg.edit({ text: "❌ 请输入内容或回复一条消息", parseMode: "html" }); return; } + + // 构建 Prompt + let q = question; + if (context) { + q = `引用消息:\n${context}\n\n用户消息:\n${question}`; } + await msg.edit({ text: "🔄 处理中...", parseMode: "html" }); + const res = await callChat(subn === "searchaudio" ? "search" : "chat", q, msg); + await executeTTS(msg, res.content, replyMsg?.id || 0); + return; } + /* ---------- TTS ---------- */ if (subn === "tts") { const replyMsg = await msg.getReplyMessage(); - const t = - (args.join(" ") || "").trim() || extractText(replyMsg).trim(); - if (!t) { - await msg.edit({ text: "❌ 请输入文本", parseMode: "html" }); - return; - } - const m = pick("tts"); - if (!m) { - await msg.edit({ text: "❌ 未设置 tts 模型", parseMode: "html" }); - return; - } - const p = providerOf(m.provider)!; - if (!p.apiKey) { - await msg.edit({ - text: "❌ 未提供令牌,请先配置 API Key(ai config add/update)", - parseMode: "html", - }); - return; - } - const compat = await resolveCompat(m.provider, m.model, p); - if (!Store.data.voices) - Store.data.voices = { gemini: "Kore", openai: "alloy" }; - const voice = - compat === "gemini" - ? Store.data.voices.gemini - : Store.data.voices.openai; - await msg.edit({ text: "🔊 合成中...", parseMode: "html" }); - const replyToId = replyMsg?.id || 0; - if (compat === "openai") { - const audio = await ttsOpenAI(p, m.model, t, voice); - await sendVoiceWithCaption(msg, audio, "", replyToId); - await msg.delete(); - return; - } else if (compat === "gemini") { - const { audio, mime } = await ttsGemini(p, m.model, t, voice); - if (audio) { - const { buf: outBuf } = convertPcmL16ToWavIfNeeded(audio, mime); - await sendVoiceWithCaption(msg, outBuf, "", replyToId); - await msg.delete(); - return; - } else { - await msg.edit({ - text: "❌ 语音合成失败:服务无有效输出", - parseMode: "html", - }); - return; - } - } else { - await msg.edit({ - text: "❌ 当前服务商不支持语音合成功能", - parseMode: "html", - }); - return; - } + const t = (args.join(" ") || "").trim() || extractQuoteOrReplyText(msg, replyMsg).trim(); + if (!t) { await msg.edit({ text: "❌ 请输入文本", parseMode: "html" }); return; } + await executeTTS(msg, t, replyMsg?.id || 0); + return; } /* ---------- 兜底 ---------- */ await msg.edit({ text: "❌ 未知子命令", parseMode: "html" }); return; } catch (e: any) { - await msg.edit({ - text: `❌ 出错:${html(mapError(e, subn))}`, - parseMode: "html", - }); + await msg.edit({ text: `❌ 出错:${html(mapError(e, subn))}`, parseMode: "html" }); return; } - }, + } }; + + // 资源清理方法 - 防止内存泄漏 + async cleanup(): Promise { + try { + // 清理 Store 写入定时器 + if (Store._writeTimer) { + clearTimeout(Store._writeTimer); + Store._writeTimer = null; + } + // 清理模型刷新防抖定时器 + if (refreshDebounceTimer) { + clearTimeout(refreshDebounceTimer); + refreshDebounceTimer = null; + } + // 清理兼容性解析缓存 + compatResolving.clear(); + // 清理 Anthropic 版本缓存 + anthropicVersionCache.clear(); + } catch { + // 清理失败时静默处理 + } + } } export default new AiPlugin(); From 78eddbd3a11c28c65f289118ef6878e933644369 Mon Sep 17 00:00:00 2001 From: brsbsc Date: Mon, 26 Jan 2026 01:39:30 +0800 Subject: [PATCH 2/5] feat: add Azure TTS plugin --- tts/tts.ts | 399 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 tts/tts.ts diff --git a/tts/tts.ts b/tts/tts.ts new file mode 100644 index 00000000..9f7a9021 --- /dev/null +++ b/tts/tts.ts @@ -0,0 +1,399 @@ +/** + * Azure TTS Plugin - 微软语音合成 + * 使用 Azure Speech Service 将文本转换为语音 + */ +import { Plugin } from "@utils/pluginBase"; +import { getPrefixes } from "@utils/pluginManager"; +import { Api } from "telegram"; +import axios from "axios"; +import { JSONFilePreset } from "lowdb/node"; +import * as path from "path"; +import { createDirectoryInAssets } from "@utils/pathHelpers"; +import { getGlobalClient } from "@utils/globalClient"; +import * as fs from "fs"; + +const prefixes = getPrefixes(); +const mainPrefix = prefixes[0]; + +/** 私聊删除命令:为双方删除;群/频道:仅自己删除 */ +async function deleteCommandMessage(msg: Api.Message) { + try { + const isPrivate = + (msg as any).isPrivate === true || + (msg.peerId instanceof (Api as any).PeerUser); + + if (isPrivate) { + await (msg as any).delete({ revoke: true }); // 双向删除 + } else { + await msg.delete(); // 普通删除 + } + } catch { } +} + +/** 清理文本(emoji/不在白名单的符号;合并连续标点) */ +function cleanTextForTTS(text: string): string { + if (!text) return ""; + let cleanedText = text; + // 移除各类 Emoji 和特殊符号 + const broadSymbolRegex = new RegExp( + "[" + + "\u{1F600}-\u{1F64F}" + // Emoticons + "\u{1F300}-\u{1F5FF}" + // Misc Symbols and Pictographs + "\u{1F680}-\u{1F6FF}" + // Transport and Map + "\u{2600}-\u{26FF}" + // Misc symbols + "\u{2700}-\u{27BF}" + // Dingbats + "\u{FE0F}" + // Variation Selectors + "\u{200D}" + // Zero-Width Joiner + "]", + "gu" + ); + cleanedText = cleanedText.replace(broadSymbolRegex, ""); + + // 仅保留中文、英文、数字和常见标点 + // const whitelistRegex = /[^\u4e00-\u9fa5a-zA-Z0-9\s,。?!、,?!.]/g; + // cleanedText = cleanedText.replace(whitelistRegex, ""); + + // 合并连续标点 + cleanedText = cleanedText.replace(/([,。?!、,?!.])\1+/g, "$1"); + // 移除 markdown 链接格式 [text](url) -> text + cleanedText = cleanedText.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1"); + + return cleanedText.trim(); +} + +// ========== 类型定义 ========== +type TTSConfig = { + key: string; + region: string; + voice: string; + style?: string; // 说话风格 + rate?: string; // 语速 (0.5 - 2.0) + format: string; +}; + +// ========== 默认配置 ========== +const DEFAULT_CONFIG: TTSConfig = { + key: "", + region: "eastus", + voice: "zh-CN-XiaoxiaoNeural", + style: "", + rate: "1.0", + format: "audio-48khz-192kbitrate-mono-mp3" +}; + +const DB_PATH = path.join(createDirectoryInAssets("tts"), "config.json"); + +// ========== 数据库 ========== +async function getDB() { + return await JSONFilePreset(DB_PATH, DEFAULT_CONFIG); +} + +// ========== 帮助文本 ========== +const getHelpText = () => `🗣️ Azure TTS (微软语音合成) + +📝 功能: +• 将文本转换为高质量语音 +• 支持多种语音、情感和语速控制 + +🔧 使用方法: +• ${mainPrefix}tts <文本> - 合成语音 +• ${mainPrefix}tts config <key> <region> - 配置 API +• ${mainPrefix}tts voice <VoiceName> - 设置语音 +• ${mainPrefix}tts style <Style> - 设置风格 (如 cheerful, sad, chat, clear) +• ${mainPrefix}tts rate <Rate> - 设置语速 (0.5 ~ 2.0, 默认为 1.0) +• ${mainPrefix}tts voices [filter] - 列出音色 (默认 zh-CN) +• ${mainPrefix}tts list - 查看当前配置 + +💡 提示: +• 样式需要该音色支持才能生效 (如 Xiaoxiao 支持 cheerful) +• 清除风格使用 ${mainPrefix}tts style clear`; + +// ========== 插件类 ========== +class TTSPlugin extends Plugin { + name = "tts"; + description = () => getHelpText(); + + cmdHandlers = { + tts: async (msg: Api.Message) => { + const text = (msg.message || "").trim(); + const parts = text.split(/\s+/).slice(1); + const subCmd = parts[0]?.toLowerCase() || ""; + + const db = await getDB(); + + // 帮助 + if (!subCmd && !msg.replyTo) { + await msg.edit({ text: getHelpText(), parseMode: "html" }); + return; + } + + // 配置 + if (subCmd === "config") { + if (parts.length < 3) { + await msg.edit({ text: `❌ 用法: ${mainPrefix}tts config `, parseMode: "html" }); + return; + } + const [, key, region] = parts; + db.data.key = key; + db.data.region = region; + await db.write(); + + // 遮挡 Key 显示 + const maskedKey = key.length > 8 ? `${key.substring(0, 4)}...${key.substring(key.length - 4)}` : "***"; + await msg.edit({ text: `✅ 配置已更新\nKey: ${maskedKey}\nRegion: ${region}`, parseMode: "html" }); + return; + } + + // 设置语音 + if (subCmd === "voice") { + if (parts.length < 2) { + await msg.edit({ text: `❌ 用法: ${mainPrefix}tts voice `, parseMode: "html" }); + return; + } + const voice = parts[1]; + db.data.voice = voice; + await db.write(); + await msg.edit({ text: `✅ 语音已设置为: ${voice}`, parseMode: "html" }); + return; + } + + // 查看配置 + if (subCmd === "list") { + const { key, region, voice, style, rate } = db.data; + const maskedKey = key ? (key.length > 8 ? `${key.substring(0, 4)}...${key.substring(key.length - 4)}` : "***") : "未设置"; + await msg.edit({ + text: `📋 当前配置\n\nKey: ${maskedKey}\nRegion: ${region}\nVoice: ${voice}\nStyle: ${style || "默认"}\nRate: ${rate || "1.0"}`, + parseMode: "html" + }); + return; + } + + // 设置风格 + if (subCmd === "style") { + const style = parts[1]; + if (!style) { + await msg.edit({ text: `❌ 用法: ${mainPrefix}tts style