// engine.jsx — 真实能力层:多引擎 AI(可扩展)+ 导入(文本/Word/图片/PDF)+ 本地保存 // ============ AI 引擎注册表(可扩展)============ // api: 'openai'(兼容 /chat/completions)| 'anthropic' | 'gemini' const AI_BUILTINS = [ { id: 'deepseek', name: 'DeepSeek', sub: '深度求索', av: 'D', api: 'openai', base: 'https://api.deepseek.com', models: ['deepseek-chat', 'deepseek-reasoner'], keyHint: 'sk-…' }, { id: 'claude', name: 'Claude', sub: 'Anthropic', av: 'C', api: 'anthropic', base: 'https://api.anthropic.com', models: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest'], keyHint: 'sk-ant-…' }, { id: 'openai', name: 'ChatGPT', sub: 'OpenAI', av: 'O', api: 'openai', base: 'https://api.openai.com/v1', models: ['gpt-4o', 'gpt-4o-mini'], keyHint: 'sk-…' }, { id: 'qwen', name: '通义千问', sub: '阿里云', av: '通', api: 'openai', base: 'https://dashscope.aliyuncs.com/compatible-mode/v1', models: ['qwen-max', 'qwen-plus', 'qwen-turbo'], keyHint: 'sk-…' }, { id: 'grok', name: 'Grok', sub: 'xAI', av: 'X', api: 'openai', base: 'https://api.x.ai/v1', models: ['grok-2-latest', 'grok-beta'], keyHint: 'xai-…' }, { id: 'gemini', name: 'Gemini', sub: 'Google', av: 'G', api: 'gemini', base: 'https://generativelanguage.googleapis.com', models: ['gemini-1.5-pro', 'gemini-1.5-flash'], keyHint: 'AIza…' }, { id: 'local', name: '本地模型', sub: 'Ollama 等', av: '本', api: 'openai', base: 'http://localhost:11434/v1', models: ['llama3.1', 'qwen2.5'], keyHint: '可留空' }, ]; function getCustomProviders() { try { return JSON.parse(localStorage.getItem('popor.ai.custom') || '[]'); } catch (_) { return []; } } function saveCustomProviders(list) { localStorage.setItem('popor.ai.custom', JSON.stringify(list)); } function addCustomProvider(p) { const list = getCustomProviders(); const id = 'custom-' + Date.now(); list.push({ id, name: p.name || '自定义引擎', sub: '自定义', av: (p.name || 'C')[0], api: p.api || 'openai', base: p.base || '', models: (p.models && p.models.length) ? p.models : ['default'], keyHint: 'API Key', custom: true }); saveCustomProviders(list); return id; } function removeCustomProvider(id) { saveCustomProviders(getCustomProviders().filter(p => p.id !== id)); localStorage.removeItem('popor.ai.key.' + id); } function getProviders() { return AI_BUILTINS.concat(getCustomProviders()); } function getProvider(id) { return getProviders().find(p => p.id === id) || AI_BUILTINS[0]; } function getActiveId() { return localStorage.getItem('popor.ai.active') || 'deepseek'; } function setActiveId(id) { localStorage.setItem('popor.ai.active', id); } function getKey(id) { return localStorage.getItem('popor.ai.key.' + id) || ''; } function setKey(id, v) { v ? localStorage.setItem('popor.ai.key.' + id, v) : localStorage.removeItem('popor.ai.key.' + id); } function getBase(id) { return localStorage.getItem('popor.ai.base.' + id) || getProvider(id).base; } function setBase(id, v) { v ? localStorage.setItem('popor.ai.base.' + id, v) : localStorage.removeItem('popor.ai.base.' + id); } function getModel(id) { return localStorage.getItem('popor.ai.model.' + id) || getProvider(id).models[0]; } function setModel(id, v) { localStorage.setItem('popor.ai.model.' + id, v); } function hasActiveKey() { const id = getActiveId(); const p = getProvider(id); return p.id === 'local' || !!getKey(id); } function activeProviderName() { return getProvider(getActiveId()).name; } // 可选代理:填了就把请求转发给你部署的代理(绕过浏览器跨域) function getProxy() { return localStorage.getItem('popor.ai.proxy') || ''; } function setProxy(v) { v ? localStorage.setItem('popor.ai.proxy', v) : localStorage.removeItem('popor.ai.proxy'); } function proxied(url) { const px = getProxy(); return px ? px.replace(/\/+$/, '') + '/' + url : url; } // 统一对话入口:按当前引擎分派 async function aiChat(messages, opts) { opts = opts || {}; const id = getActiveId(); const p = getProvider(id); const key = getKey(id); if (!key && p.id !== 'local') { const e = new Error('未配置「' + p.name + '」密钥'); e.code = 'NO_KEY'; throw e; } const base = getBase(id).replace(/\/+$/, ''); const model = opts.model || getModel(id); try { if (p.api === 'anthropic') return await callAnthropic(base, key, model, messages, opts); if (p.api === 'gemini') return await callGemini(base, key, model, messages, opts); return await callOpenAI(base, key, model, messages, opts); } catch (err) { if (err.code) throw err; const e = new Error('网络/跨域受阻:浏览器未能直连「' + p.name + '」。可改用服务端代理。'); e.code = 'NETWORK'; e.cause = err; throw e; } } async function httpJson(url, init) { const res = await fetch(url, init); if (!res.ok) { let detail = ''; try { const j = await res.json(); detail = (j.error && (j.error.message || j.error.type)) || ''; } catch (_) {} const e = new Error('返回 ' + res.status + (detail ? ' · ' + detail : '')); e.code = res.status === 401 ? 'AUTH' : 'HTTP'; throw e; } return res.json(); } async function callOpenAI(base, key, model, messages, opts) { const headers = { 'Content-Type': 'application/json' }; if (key) headers['Authorization'] = 'Bearer ' + key; const data = await httpJson(proxied(base + '/chat/completions'), { method: 'POST', headers, signal: opts.signal, body: JSON.stringify({ model, messages, temperature: opts.temperature != null ? opts.temperature : 0.7, stream: false }), }); return (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || ''; } async function callAnthropic(base, key, model, messages, opts) { const sys = messages.filter(m => m.role === 'system').map(m => m.content).join('\n'); const msgs = messages.filter(m => m.role !== 'system').map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content })); const data = await httpJson(proxied(base + '/v1/messages'), { method: 'POST', signal: opts.signal, headers: { 'Content-Type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }, body: JSON.stringify({ model, max_tokens: opts.max_tokens || 1024, system: sys || undefined, messages: msgs, temperature: opts.temperature != null ? opts.temperature : 0.7 }), }); return (data.content && data.content.map(c => c.text || '').join('')) || ''; } async function callGemini(base, key, model, messages, opts) { const sys = messages.filter(m => m.role === 'system').map(m => m.content).join('\n'); const contents = messages.filter(m => m.role !== 'system').map(m => ({ role: m.role === 'assistant' ? 'model' : 'user', parts: [{ text: m.content }] })); const body = { contents }; if (sys) body.systemInstruction = { parts: [{ text: sys }] }; const data = await httpJson(proxied(base + '/v1beta/models/' + model + ':generateContent?key=' + encodeURIComponent(key)), { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: opts.signal, body: JSON.stringify(body), }); const cand = data.candidates && data.candidates[0]; return (cand && cand.content && cand.content.parts && cand.content.parts.map(p => p.text || '').join('')) || ''; } async function aiPing() { return (await aiChat([{ role: 'user', content: '回复两个字:在的' }], { temperature: 0, max_tokens: 16 })).trim(); } // ============ 语音引擎(TTS + 克隆)============ // 国外:ElevenLabs(克隆+合成);国内:千问 Qwen-TTS、CosyVoice(通义/阿里云百炼,需走代理/服务端) const TTS_PROVIDERS = [ { id: 'elevenlabs', name: 'ElevenLabs', sub: '国外 · 克隆+合成', keyHint: 'ElevenLabs API Key' }, { id: 'qwen', name: '千问 · Qwen-TTS', sub: '国内 · 阿里云百炼', keyHint: 'DashScope API Key' }, { id: 'cosyvoice', name: 'CosyVoice', sub: '国内 · 通义/阿里云百炼', keyHint: 'DashScope API Key' }, ]; const EL_VOICES = [ { id: '21m00Tcm4TlvDq8ikWAM', name: 'Rachel · 沉稳女声' }, { id: 'pNInz6obpgDQGcFmaJgB', name: 'Adam · 磁性男声' }, { id: 'EXAVITQu4vr4xnSDxMaL', name: 'Bella · 温柔女声' }, ]; // 千问 Qwen-TTS 内置音色(阿里云百炼) const QWEN_VOICES = [ { id: 'Cherry', name: 'Cherry · 清亮女声' }, { id: 'Serena', name: 'Serena · 沉稳女声' }, { id: 'Ethan', name: 'Ethan · 温润男声' }, { id: 'Chelsie', name: 'Chelsie · 亲切女声' }, ]; function ttsVoiceList(prov) { return (prov || getTtsProvider()) === 'qwen' ? QWEN_VOICES : EL_VOICES; } function getTtsProvider() { return localStorage.getItem('popor.tts.prov') || 'elevenlabs'; } function setTtsProvider(v) { localStorage.setItem('popor.tts.prov', v); } function getTtsKey(prov) { return localStorage.getItem('popor.tts.key.' + (prov || getTtsProvider())) || ''; } function setTtsKey(prov, v) { v ? localStorage.setItem('popor.tts.key.' + prov, v) : localStorage.removeItem('popor.tts.key.' + prov); } function getTtsVoice() { const prov = getTtsProvider(); const stored = localStorage.getItem('popor.tts.voice.' + prov) || localStorage.getItem('popor.tts.voice'); const list = ttsVoiceList(prov); return (stored && list.some(v => v.id === stored)) ? stored : list[0].id; } function setTtsVoice(v) { localStorage.setItem('popor.tts.voice', v); localStorage.setItem('popor.tts.voice.' + getTtsProvider(), v); } function getClonedVoice() { try { return JSON.parse(localStorage.getItem('popor.tts.cloned') || 'null'); } catch (_) { return null; } } function setClonedVoice(v) { v ? localStorage.setItem('popor.tts.cloned', JSON.stringify(v)) : localStorage.removeItem('popor.tts.cloned'); } function hasTts() { return !!getTtsKey(); } // 文本 → 语音,返回可播放的 blob URL async function ttsSynthesize(text, opts) { opts = opts || {}; const prov = getTtsProvider(); const key = getTtsKey(prov); if (!key) { const e = new Error('未配置语音引擎密钥'); e.code = 'NO_KEY'; throw e; } // 千问 Qwen-TTS(阿里云百炼)—— 返回音频 URL,再取回为 blob if (prov === 'qwen') { const voice = opts.voiceId || getTtsVoice(); let res; try { res = await fetch(proxied('https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation'), { method: 'POST', headers: { 'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'qwen-tts', input: { text: text, voice: voice } }), }); } catch (err) { const e = new Error('千问 TTS 网络/跨域受阻,建议在墨吞·进阶填入代理地址'); e.code = 'NETWORK'; throw e; } if (!res.ok) { let d = ''; try { const j = await res.json(); d = j.message || (j.code || ''); } catch (_) {} const e = new Error('千问 TTS 失败 ' + res.status + (d ? ' · ' + d : '')); e.code = res.status === 401 ? 'AUTH' : 'HTTP'; throw e; } const data = await res.json(); const url = data && data.output && data.output.audio && (data.output.audio.url || data.output.audio.data); if (!url) { const e = new Error('千问 TTS 未返回音频'); e.code = 'EMPTY'; throw e; } try { return URL.createObjectURL(await (await fetch(proxied(url))).blob()); } catch (_) { return url; } // 取回受阻时直接给 URL,