-
Notifications
You must be signed in to change notification settings - Fork 61.1k
Feature/ support (alibaba tts + alibaba function calling + network search) #6588
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 4 commits
9990a89
c5e6b12
e836dc0
221229c
fe484fd
4e3f166
044298e
86f2c67
9cb7275
45eb96f
b73e65d
800c96c
16c3255
bf999b9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
nodeLinker: node-modules |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,10 @@ | ||
"use client"; | ||
import { ApiPath, Alibaba, ALIBABA_BASE_URL } from "@/app/constant"; | ||
import { | ||
ApiPath, | ||
Alibaba, | ||
ALIBABA_BASE_URL, | ||
REQUEST_TIMEOUT_MS, | ||
} from "@/app/constant"; | ||
import { | ||
useAccessStore, | ||
useAppConfig, | ||
|
@@ -89,10 +94,72 @@ export class QwenApi implements LLMApi { | |
return res?.output?.choices?.at(0)?.message?.content ?? ""; | ||
} | ||
|
||
speech(options: SpeechOptions): Promise<ArrayBuffer> { | ||
async speech(options: SpeechOptions): Promise<ArrayBuffer> { | ||
throw new Error("Method not implemented."); | ||
} | ||
|
||
async *streamSpeech(options: SpeechOptions): AsyncGenerator<AudioBuffer> { | ||
const requestPayload = { | ||
model: options.model, | ||
input: { | ||
text: options.input, | ||
voice: options.voice, | ||
}, | ||
speed: options.speed, | ||
response_format: options.response_format, | ||
}; | ||
const controller = new AbortController(); | ||
options.onController?.(controller); | ||
try { | ||
const speechPath = this.path(Alibaba.SpeechPath); | ||
const speechPayload = { | ||
method: "POST", | ||
body: JSON.stringify(requestPayload), | ||
signal: controller.signal, | ||
headers: { | ||
...getHeaders(), | ||
"X-DashScope-SSE": "enable", | ||
}, | ||
}; | ||
Comment on lines
+124
to
+132
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Harden SSE request: set headers, check res.ok/body, and clear timeout on all paths Missing Accept/Content-Type, no res.ok check, and no guard for res.body. Also ensure timeout is cleared in finally. const speechPayload = {
method: "POST",
body: JSON.stringify(requestPayload),
signal: controller.signal,
headers: {
...getHeaders(),
"X-DashScope-SSE": "enable",
+ Accept: "text/event-stream",
+ "Content-Type": "application/json",
},
};
@@
- const res = await fetch(speechPath, speechPayload);
- clearTimeout(requestTimeoutId); // Clear timeout on successful connection
+ const res = await fetch(speechPath, speechPayload);
+ if (!res.ok) {
+ const errText = await res.text().catch(() => "");
+ throw new Error(
+ `[Alibaba TTS] HTTP ${res.status} ${res.statusText} ${errText}`,
+ );
+ }
+ if (!res.body) {
+ throw new Error("[Alibaba TTS] Missing response body for SSE stream.");
+ } And move timeout cleanup into finally (see next comment). Also applies to: 140-146 🤖 Prompt for AI Agents
|
||
|
||
// make a fetch request | ||
const requestTimeoutId = setTimeout( | ||
() => controller.abort(), | ||
REQUEST_TIMEOUT_MS, | ||
); | ||
|
||
const res = await fetch(speechPath, speechPayload); | ||
|
||
const reader = res.body!.getReader(); | ||
const decoder = new TextDecoder(); | ||
let buffer = ""; | ||
while (true) { | ||
const { done, value } = await reader.read(); | ||
if (done) { | ||
break; | ||
} | ||
buffer += decoder.decode(value, { stream: true }); | ||
const lines = buffer.split("\n"); | ||
buffer = lines.pop() || ""; | ||
|
||
for (const line of lines) { | ||
if (line.startsWith("data:")) { | ||
const data = line.slice(5); | ||
const json = JSON.parse(data); | ||
if (json.output.audio.data) { | ||
yield this.PCMBase64ToAudioBuffer(json.output.audio.data); | ||
} | ||
} | ||
} | ||
} | ||
clearTimeout(requestTimeoutId); | ||
reader.releaseLock(); | ||
} catch (e) { | ||
console.log("[Request] failed to make a speech request", e); | ||
throw e; | ||
} | ||
} | ||
Little-LittleProgrammer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
async chat(options: ChatOptions) { | ||
const modelConfig = { | ||
...useAppConfig.getState().modelConfig, | ||
|
@@ -273,5 +340,72 @@ export class QwenApi implements LLMApi { | |
async models(): Promise<LLMModel[]> { | ||
return []; | ||
} | ||
|
||
// 播放 PCM base64 数据 | ||
private async PCMBase64ToAudioBuffer(base64Data: string) { | ||
try { | ||
// 解码 base64 | ||
const binaryString = atob(base64Data); | ||
const bytes = new Uint8Array(binaryString.length); | ||
for (let i = 0; i < binaryString.length; i++) { | ||
bytes[i] = binaryString.charCodeAt(i); | ||
} | ||
|
||
// 转换为 AudioBuffer | ||
const audioBuffer = await this.convertToAudioBuffer(bytes); | ||
|
||
return audioBuffer; | ||
} catch (error) { | ||
console.error("播放 PCM 数据失败:", error); | ||
throw error; | ||
} | ||
} | ||
Little-LittleProgrammer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// 将 PCM 字节数据转换为 AudioBuffer | ||
private convertToAudioBuffer(pcmData: Uint8Array) { | ||
const audioContext = new (window.AudioContext || | ||
window.webkitAudioContext)(); | ||
Little-LittleProgrammer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const channels = 1; | ||
const sampleRate = 24000; | ||
return new Promise<AudioBuffer>((resolve, reject) => { | ||
try { | ||
let float32Array; | ||
// 16位 PCM 转换为 32位浮点数 | ||
float32Array = this.pcm16ToFloat32(pcmData); | ||
|
||
// 创建 AudioBuffer | ||
const audioBuffer = audioContext.createBuffer( | ||
channels, | ||
float32Array.length / channels, | ||
sampleRate, | ||
); | ||
|
||
// 复制数据到 AudioBuffer | ||
for (let channel = 0; channel < channels; channel++) { | ||
const channelData = audioBuffer.getChannelData(channel); | ||
for (let i = 0; i < channelData.length; i++) { | ||
channelData[i] = float32Array[i * channels + channel]; | ||
} | ||
} | ||
|
||
resolve(audioBuffer); | ||
} catch (error) { | ||
reject(error); | ||
} | ||
}); | ||
} | ||
// 16位 PCM 转 32位浮点数 | ||
private pcm16ToFloat32(pcmData: Uint8Array) { | ||
const length = pcmData.length / 2; | ||
const float32Array = new Float32Array(length); | ||
|
||
for (let i = 0; i < length; i++) { | ||
const int16 = (pcmData[i * 2 + 1] << 8) | pcmData[i * 2]; | ||
const int16Signed = int16 > 32767 ? int16 - 65536 : int16; | ||
float32Array[i] = int16Signed / 32768; | ||
} | ||
|
||
return float32Array; | ||
} | ||
} | ||
export { Alibaba }; |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -101,8 +101,6 @@ import { | |||||||||||||||||||||
import { useNavigate } from "react-router-dom"; | ||||||||||||||||||||||
import { | ||||||||||||||||||||||
CHAT_PAGE_SIZE, | ||||||||||||||||||||||
DEFAULT_TTS_ENGINE, | ||||||||||||||||||||||
ModelProvider, | ||||||||||||||||||||||
Path, | ||||||||||||||||||||||
REQUEST_TIMEOUT_MS, | ||||||||||||||||||||||
ServiceProvider, | ||||||||||||||||||||||
|
@@ -1286,50 +1284,83 @@ function _Chat() { | |||||||||||||||||||||
const accessStore = useAccessStore(); | ||||||||||||||||||||||
const [speechStatus, setSpeechStatus] = useState(false); | ||||||||||||||||||||||
const [speechLoading, setSpeechLoading] = useState(false); | ||||||||||||||||||||||
const [speechCooldown, setSpeechCooldown] = useState(false); | ||||||||||||||||||||||
|
||||||||||||||||||||||
async function openaiSpeech(text: string) { | ||||||||||||||||||||||
if (speechStatus) { | ||||||||||||||||||||||
ttsPlayer.stop(); | ||||||||||||||||||||||
setSpeechStatus(false); | ||||||||||||||||||||||
} else { | ||||||||||||||||||||||
Comment on lines
1312
to
1316
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Await ttsPlayer.stop() to match the async API and ensure cleanup before toggling state After aligning TTSPlayer.stop to return Promise, await it here to avoid races (e.g., stop finishes after UI flips status). - if (speechStatus) {
- ttsPlayer.stop();
+ if (speechStatus) {
+ await ttsPlayer.stop();
setSpeechStatus(false);
} else { 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||
var api: ClientApi; | ||||||||||||||||||||||
api = new ClientApi(ModelProvider.GPT); | ||||||||||||||||||||||
const config = useAppConfig.getState(); | ||||||||||||||||||||||
api = new ClientApi(config.ttsConfig.modelProvider); | ||||||||||||||||||||||
setSpeechLoading(true); | ||||||||||||||||||||||
ttsPlayer.init(); | ||||||||||||||||||||||
let audioBuffer: ArrayBuffer; | ||||||||||||||||||||||
let audioBuffer: ArrayBuffer | AudioBuffer; | ||||||||||||||||||||||
const { markdownToTxt } = require("markdown-to-txt"); | ||||||||||||||||||||||
const textContent = markdownToTxt(text); | ||||||||||||||||||||||
if (config.ttsConfig.engine !== DEFAULT_TTS_ENGINE) { | ||||||||||||||||||||||
if (config.ttsConfig.engine === "Edge") { | ||||||||||||||||||||||
const edgeVoiceName = accessStore.edgeVoiceName(); | ||||||||||||||||||||||
const tts = new MsEdgeTTS(); | ||||||||||||||||||||||
await tts.setMetadata( | ||||||||||||||||||||||
edgeVoiceName, | ||||||||||||||||||||||
OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3, | ||||||||||||||||||||||
); | ||||||||||||||||||||||
audioBuffer = await tts.toArrayBuffer(textContent); | ||||||||||||||||||||||
playSpeech(audioBuffer); | ||||||||||||||||||||||
} else { | ||||||||||||||||||||||
audioBuffer = await api.llm.speech({ | ||||||||||||||||||||||
model: config.ttsConfig.model, | ||||||||||||||||||||||
input: textContent, | ||||||||||||||||||||||
voice: config.ttsConfig.voice, | ||||||||||||||||||||||
speed: config.ttsConfig.speed, | ||||||||||||||||||||||
}); | ||||||||||||||||||||||
if (api.llm.streamSpeech) { | ||||||||||||||||||||||
// 使用流式播放,边接收边播放 | ||||||||||||||||||||||
setSpeechStatus(true); | ||||||||||||||||||||||
ttsPlayer.startStreamPlay(() => { | ||||||||||||||||||||||
setSpeechStatus(false); | ||||||||||||||||||||||
}); | ||||||||||||||||||||||
|
||||||||||||||||||||||
try { | ||||||||||||||||||||||
for await (const chunk of api.llm.streamSpeech({ | ||||||||||||||||||||||
model: config.ttsConfig.model, | ||||||||||||||||||||||
input: textContent, | ||||||||||||||||||||||
voice: config.ttsConfig.voice, | ||||||||||||||||||||||
speed: config.ttsConfig.speed, | ||||||||||||||||||||||
})) { | ||||||||||||||||||||||
ttsPlayer.addToQueue(chunk); | ||||||||||||||||||||||
} | ||||||||||||||||||||||
ttsPlayer.finishStreamPlay(); | ||||||||||||||||||||||
} catch (e) { | ||||||||||||||||||||||
console.error("[Stream Speech]", e); | ||||||||||||||||||||||
showToast(prettyObject(e)); | ||||||||||||||||||||||
setSpeechStatus(false); | ||||||||||||||||||||||
ttsPlayer.stop(); | ||||||||||||||||||||||
} finally { | ||||||||||||||||||||||
setSpeechLoading(false); | ||||||||||||||||||||||
} | ||||||||||||||||||||||
} else { | ||||||||||||||||||||||
audioBuffer = await api.llm.speech({ | ||||||||||||||||||||||
model: config.ttsConfig.model, | ||||||||||||||||||||||
input: textContent, | ||||||||||||||||||||||
voice: config.ttsConfig.voice, | ||||||||||||||||||||||
speed: config.ttsConfig.speed, | ||||||||||||||||||||||
}); | ||||||||||||||||||||||
playSpeech(audioBuffer); | ||||||||||||||||||||||
} | ||||||||||||||||||||||
} | ||||||||||||||||||||||
Little-LittleProgrammer marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||
setSpeechStatus(true); | ||||||||||||||||||||||
ttsPlayer | ||||||||||||||||||||||
.play(audioBuffer, () => { | ||||||||||||||||||||||
setSpeechStatus(false); | ||||||||||||||||||||||
}) | ||||||||||||||||||||||
.catch((e) => { | ||||||||||||||||||||||
console.error("[OpenAI Speech]", e); | ||||||||||||||||||||||
showToast(prettyObject(e)); | ||||||||||||||||||||||
setSpeechStatus(false); | ||||||||||||||||||||||
}) | ||||||||||||||||||||||
.finally(() => setSpeechLoading(false)); | ||||||||||||||||||||||
} | ||||||||||||||||||||||
} | ||||||||||||||||||||||
|
||||||||||||||||||||||
function playSpeech(audioBuffer: ArrayBuffer | AudioBuffer) { | ||||||||||||||||||||||
setSpeechStatus(true); | ||||||||||||||||||||||
ttsPlayer | ||||||||||||||||||||||
.play(audioBuffer, () => { | ||||||||||||||||||||||
setSpeechStatus(false); | ||||||||||||||||||||||
}) | ||||||||||||||||||||||
.catch((e) => { | ||||||||||||||||||||||
console.error("[OpenAI Speech]", e); | ||||||||||||||||||||||
showToast(prettyObject(e)); | ||||||||||||||||||||||
setSpeechStatus(false); | ||||||||||||||||||||||
}) | ||||||||||||||||||||||
.finally(() => setSpeechLoading(false)); | ||||||||||||||||||||||
} | ||||||||||||||||||||||
|
||||||||||||||||||||||
const context: RenderMessage[] = useMemo(() => { | ||||||||||||||||||||||
return session.mask.hideContext ? [] : session.mask.context.slice(); | ||||||||||||||||||||||
}, [session.mask.context, session.mask.hideContext]); | ||||||||||||||||||||||
|
Uh oh!
There was an error while loading. Please reload this page.