This commit is contained in:
Abe Jellinek 2026-08-27 17:20:16 +08:00 committed by GitHub
commit e10ac2b835
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 142 additions and 0 deletions

View file

@ -617,7 +617,99 @@ Zotero.Sync.APIClient.prototype = {
},
// Hidden prefs for Read Aloud custom endpoints:
// extensions.zotero.reader.readAloud.customEndpoint
// "{mode}=[url]". Empty/invalid falls back to the normal Zotero API
// endpoint. Only "openai" is currently supported as a mode, where the
// server must implement OpenAI's /audio/speech
// (e.g. https://api.openai.com/v1).
// extensions.zotero.reader.readAloud.customEndpoint.voices
// Comma-separated voice IDs (openai mode only). Defaults to the
// OpenAI TTS voice list.
// extensions.zotero.reader.readAloud.customEndpoint.apiKey
// Sent as "Authorization: Bearer <key>" (openai mode only).
// extensions.zotero.reader.readAloud.customEndpoint.model
// Model name sent in the request body (openai mode only).
_getReadAloudCustomEndpoint() {
let pref = Zotero.Prefs.get('reader.readAloud.customEndpoint');
if (!pref || typeof pref !== 'string') return null;
let match = pref.match(/^(\w+)=(.+)$/);
if (!match) return null;
let [, mode, url] = match;
if (mode !== 'openai') {
Zotero.debug(`Unsupported customEndpoint mode '${mode}'`);
return null;
}
try {
// eslint-disable-next-line no-new
new URL(url);
}
catch {
Zotero.debug('Invalid customEndpoint pref value: ' + pref);
return null;
}
if (!url.endsWith('/')) {
url += '/';
}
let voicesPref = Zotero.Prefs.get('reader.readAloud.customEndpoint.voices');
let voices = typeof voicesPref === 'string'
? voicesPref.split(',').map(s => s.trim()).filter(Boolean)
: null;
let apiKey = Zotero.Prefs.get('reader.readAloud.customEndpoint.apiKey');
if (typeof apiKey !== 'string' || !apiKey) {
apiKey = null;
}
let model = Zotero.Prefs.get('reader.readAloud.customEndpoint.model');
if (typeof model !== 'string' || !model) {
model = 'gpt-4o-mini-tts';
}
return { mode, url, voices, apiKey, model };
},
async getReadAloudVoices() {
let custom = this._getReadAloudCustomEndpoint();
if (custom?.mode === 'openai') {
// Fall back to OpenAI's voice IDs
let voiceIDs = custom.voices ?? [
'alloy',
'ash',
'ballad',
'coral',
'echo',
'fable',
'nova',
'onyx',
'sage',
'shimmer',
'verse',
];
let voices = Object.fromEntries(voiceIDs.map(id => [id, { label: id }]));
// Derive a cache version from the endpoint and model so that
// changing either one invalidates previously cached audio
let cacheVersion = parseInt(
Zotero.Utilities.Internal.md5(custom.url + '|' + custom.model).slice(0, 8),
16
);
// OpenAI's TTS endpoint auto-detects language from the input
// text, so use the wildcard locale.
// The reader treats '*' as matching any language.
return {
voices: {
standard: [{
creditsPerMinute: 0,
segmentGranularity: 'sentence',
cacheVersion,
voices,
locales: { '*': voiceIDs },
}],
},
standardCreditsRemaining: null,
premiumCreditsRemaining: null,
devMode: false,
};
}
let url = this.baseURL + "tts/voices";
let params = new URLSearchParams();
params.set("lang", Services.locale.appLocaleAsBCP47);
@ -669,6 +761,47 @@ Zotero.Sync.APIClient.prototype = {
async getReadAloudAudio(segment, voiceID) {
let custom = this._getReadAloudCustomEndpoint();
if (custom?.mode === 'openai') {
let text = segment === 'sample'
? "Isn't it nice to have a computer that will talk to you?"
: segment.text;
let headers = { "Content-Type": "application/json" };
if (custom.apiKey) {
headers.Authorization = `Bearer ${custom.apiKey}`;
}
try {
let xmlhttp = await Zotero.HTTP.request("POST", custom.url + "audio/speech", {
responseType: "blob",
headers,
body: JSON.stringify({
model: custom.model,
voice: voiceID,
input: text,
// eslint-disable-next-line camelcase
response_format: 'mp3',
}),
errorDelayMax: 8000,
});
return { audio: xmlhttp.response };
}
catch (e) {
Zotero.logError(e);
let error;
if (e instanceof Zotero.HTTP.BrowserOfflineException) {
error = 'network';
}
else {
error = 'unknown';
}
return {
audio: null,
error,
};
}
}
let method;
let url;
let options = {
@ -741,6 +874,10 @@ Zotero.Sync.APIClient.prototype = {
async getReadAloudCreditsRemaining() {
let custom = this._getReadAloudCustomEndpoint();
if (custom?.mode === 'openai') {
return { standardCreditsRemaining: null, premiumCreditsRemaining: null };
}
let uri = this.baseURL + "tts/credits";
try {
let xmlhttp = await this.makeRequest("GET", uri, {
@ -761,6 +898,10 @@ Zotero.Sync.APIClient.prototype = {
async resetReadAloudCredits() {
let custom = this._getReadAloudCustomEndpoint();
if (custom?.mode === 'openai') {
return { standardCreditsRemaining: null, premiumCreditsRemaining: null };
}
let uri = this.baseURL + "tts/reset";
try {
let xmlhttp = await this.makeRequest("POST", uri, {

View file

@ -251,6 +251,7 @@ pref("extensions.zotero.reader.autoDisableTool.image", true);
pref("extensions.zotero.reader.lastSidebarTab", "annotations");
pref("extensions.zotero.reader.popupPositions", "{}");
pref("extensions.zotero.reader.readAloud.highlightGranularity", "sentence");
pref("extensions.zotero.reader.readAloud.customEndpoint.model", "gpt-4o-mini-tts");
// Set color scheme to auto by default
pref("browser.theme.toolbar-theme", 2);