-
Notifications
You must be signed in to change notification settings - Fork 61k
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 all 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 |
---|---|---|
|
@@ -6,7 +6,9 @@ import { | |
useChatStore, | ||
ChatMessageTool, | ||
usePluginStore, | ||
FunctionToolItem, | ||
} from "@/app/store"; | ||
import { TTSPlayManager } from "@/app/utils/audio"; | ||
import { | ||
preProcessImageContentForAlibabaDashScope, | ||
streamWithThink, | ||
|
@@ -51,6 +53,8 @@ interface RequestParam { | |
repetition_penalty?: number; | ||
top_p: number; | ||
max_tokens?: number; | ||
tools?: FunctionToolItem[]; | ||
enable_search?: boolean; | ||
} | ||
interface RequestPayload { | ||
model: string; | ||
|
@@ -89,10 +93,102 @@ 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, | ||
audioManager?: TTSPlayManager, | ||
): AsyncGenerator<AudioBuffer> { | ||
if (!options.input || !options.model) { | ||
throw new Error("Missing required parameters: input and model"); | ||
} | ||
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); | ||
|
||
if (audioManager) { | ||
audioManager.setStreamController(controller); | ||
} | ||
try { | ||
Comment on lines
+100
to
+122
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. audioManager is optional but used with non-null assertion; guard or make it required streamSpeech() references audioManager! which will throw if undefined. Either require the param or create a local TTSPlayManager fallback so decoding still works. - async *streamSpeech(
- options: SpeechOptions,
- audioManager?: TTSPlayManager,
- ): AsyncGenerator<AudioBuffer> {
+ async *streamSpeech(
+ options: SpeechOptions,
+ audioManager?: TTSPlayManager,
+ ): AsyncGenerator<AudioBuffer> {
@@
- if (audioManager) {
- audioManager.setStreamController(controller);
- }
+ const player = audioManager ?? new TTSPlayManager();
+ player.setStreamController(controller);
@@
- if (json.output?.audio?.data) {
- yield await audioManager!.pcmBase64ToAudioBuffer(
- json.output.audio.data,
- { channels: 1, sampleRate: 24000, bitDepth: 16 },
- );
- }
+ if (json.output?.audio?.data) {
+ const sr = json.output?.audio?.sample_rate ?? 24000;
+ yield await player.pcmBase64ToAudioBuffer(
+ json.output.audio.data,
+ { channels: 1, sampleRate: sr, bitDepth: 16 },
+ );
+ }
@@
- if (audioManager) {
- audioManager.clearStreamController();
- }
+ player.clearStreamController(); Also applies to: 161-165, 185-189 🤖 Prompt for AI Agents
|
||
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(), | ||
getTimeoutMSByModel(options.model), | ||
); | ||
|
||
const res = await fetch(speechPath, speechPayload); | ||
clearTimeout(requestTimeoutId); // Clear timeout on successful connection | ||
|
||
Little-LittleProgrammer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) { | ||
const data = line.slice(5); | ||
try { | ||
if (line.startsWith("data:")) { | ||
const json = JSON.parse(data); | ||
if (json.output?.audio?.data) { | ||
yield await audioManager!.pcmBase64ToAudioBuffer( | ||
json.output.audio.data, | ||
{ channels: 1, sampleRate: 24000, bitDepth: 16 }, | ||
); | ||
} | ||
} | ||
} catch (parseError) { | ||
console.warn( | ||
"[StreamSpeech] Failed to parse SSE data:", | ||
parseError, | ||
); | ||
continue; | ||
} | ||
} | ||
} | ||
reader.releaseLock(); | ||
Comment on lines
+135
to
+176
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 Always release reader and clear timeout (finally), and improve SSE parsing Ensure requestTimeoutId and reader are cleaned up on all paths. Also, only slice when the line starts with data:, and handle the [DONE] sentinel. Keep parsing in try/catch. - const requestTimeoutId = setTimeout(
+ let requestTimeoutId: any = setTimeout(
() => controller.abort(),
getTimeoutMSByModel(options.model),
);
-
- const res = await fetch(speechPath, speechPayload);
- const reader = res.body!.getReader();
+ const res = await fetch(speechPath, speechPayload);
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
+ 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) {
- const data = line.slice(5);
+ for (const rawLine of lines) {
+ const line = rawLine.trim();
try {
- if (line.startsWith("data:")) {
- const json = JSON.parse(data);
+ if (!line.startsWith("data:")) continue;
+ const data = line.slice(5).trim();
+ if (data === "[DONE]") {
+ // end-of-stream marker
+ break;
+ }
+ const json = JSON.parse(data);
if (json.output?.audio?.data) {
- yield await player.pcmBase64ToAudioBuffer(
- json.output.audio.data,
- { channels: 1, sampleRate: 24000, bitDepth: 16 },
- );
+ const sr = json.output?.audio?.sample_rate ?? 24000;
+ yield await player.pcmBase64ToAudioBuffer(
+ json.output.audio.data,
+ { channels: 1, sampleRate: sr, bitDepth: 16 },
+ );
}
} catch (parseError) {
console.warn(
"[StreamSpeech] Failed to parse SSE data:",
parseError,
);
continue;
}
}
}
- reader.releaseLock();
+ reader?.releaseLock();
@@
- } finally {
- if (audioManager) {
- audioManager.clearStreamController();
- }
+ } finally {
+ try {
+ clearTimeout(requestTimeoutId);
+ } catch {}
+ try {
+ // releasing is idempotent; safe to attempt
+ // @ts-ignore - reader may be undefined in some error paths
+ reader?.releaseLock?.();
+ } catch {}
+ try {
+ (audioManager ?? player)?.clearStreamController();
+ } catch {}
} Also applies to: 185-189 🤖 Prompt for AI Agents
|
||
} catch (e) { | ||
// 如果是用户主动取消(AbortError),则不作为错误处理 | ||
if (e instanceof Error && e.name === "AbortError") { | ||
console.log("[Request] Stream speech was aborted by user"); | ||
return; // 正常退出,不抛出错误 | ||
} | ||
console.log("[Request] failed to make a speech request", e); | ||
throw e; | ||
} finally { | ||
if (audioManager) { | ||
audioManager.clearStreamController(); | ||
} | ||
} | ||
} | ||
|
||
async chat(options: ChatOptions) { | ||
const modelConfig = { | ||
...useAppConfig.getState().modelConfig, | ||
|
@@ -129,6 +225,7 @@ export class QwenApi implements LLMApi { | |
temperature: modelConfig.temperature, | ||
// max_tokens: modelConfig.max_tokens, | ||
top_p: modelConfig.top_p === 1 ? 0.99 : modelConfig.top_p, // qwen top_p is should be < 1 | ||
enable_search: modelConfig.enableNetWork, | ||
}, | ||
}; | ||
|
||
|
@@ -161,11 +258,16 @@ export class QwenApi implements LLMApi { | |
.getAsTools( | ||
useChatStore.getState().currentSession().mask?.plugin || [], | ||
); | ||
// console.log("getAsTools", tools, funcs); | ||
const _tools = tools as unknown as FunctionToolItem[]; | ||
if (_tools && _tools.length > 0) { | ||
requestPayload.parameters.tools = _tools; | ||
} | ||
return streamWithThink( | ||
chatPath, | ||
requestPayload, | ||
headers, | ||
tools as any, | ||
[], | ||
funcs, | ||
controller, | ||
// parseSSE | ||
|
@@ -198,7 +300,7 @@ export class QwenApi implements LLMApi { | |
}); | ||
} else { | ||
// @ts-ignore | ||
runTools[index]["function"]["arguments"] += args; | ||
runTools[index]["function"]["arguments"] += args || ""; | ||
} | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -48,6 +48,7 @@ import PluginIcon from "../icons/plugin.svg"; | |||||||||||||||||||||
import ShortcutkeyIcon from "../icons/shortcutkey.svg"; | ||||||||||||||||||||||
import McpToolIcon from "../icons/tool.svg"; | ||||||||||||||||||||||
import HeadphoneIcon from "../icons/headphone.svg"; | ||||||||||||||||||||||
import NetWorkIcon from "../icons/network.svg"; | ||||||||||||||||||||||
import { | ||||||||||||||||||||||
BOT_HELLO, | ||||||||||||||||||||||
ChatMessage, | ||||||||||||||||||||||
|
@@ -75,6 +76,7 @@ import { | |||||||||||||||||||||
useMobileScreen, | ||||||||||||||||||||||
selectOrCopy, | ||||||||||||||||||||||
showPlugins, | ||||||||||||||||||||||
canUseNetWork, | ||||||||||||||||||||||
} from "../utils"; | ||||||||||||||||||||||
|
||||||||||||||||||||||
import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; | ||||||||||||||||||||||
|
@@ -101,8 +103,6 @@ import { | |||||||||||||||||||||
import { useNavigate } from "react-router-dom"; | ||||||||||||||||||||||
import { | ||||||||||||||||||||||
CHAT_PAGE_SIZE, | ||||||||||||||||||||||
DEFAULT_TTS_ENGINE, | ||||||||||||||||||||||
ModelProvider, | ||||||||||||||||||||||
Path, | ||||||||||||||||||||||
REQUEST_TIMEOUT_MS, | ||||||||||||||||||||||
ServiceProvider, | ||||||||||||||||||||||
|
@@ -512,6 +512,7 @@ export function ChatActions(props: { | |||||||||||||||||||||
|
||||||||||||||||||||||
// switch themes | ||||||||||||||||||||||
const theme = config.theme; | ||||||||||||||||||||||
const enableNetWork = session.mask.modelConfig.enableNetWork || false; | ||||||||||||||||||||||
|
||||||||||||||||||||||
function nextTheme() { | ||||||||||||||||||||||
const themes = [Theme.Auto, Theme.Light, Theme.Dark]; | ||||||||||||||||||||||
|
@@ -521,6 +522,13 @@ export function ChatActions(props: { | |||||||||||||||||||||
config.update((config) => (config.theme = nextTheme)); | ||||||||||||||||||||||
} | ||||||||||||||||||||||
|
||||||||||||||||||||||
function nextNetWork() { | ||||||||||||||||||||||
chatStore.updateTargetSession(session, (session) => { | ||||||||||||||||||||||
session.mask.modelConfig.enableNetWork = | ||||||||||||||||||||||
!session.mask.modelConfig.enableNetWork; | ||||||||||||||||||||||
}); | ||||||||||||||||||||||
} | ||||||||||||||||||||||
|
||||||||||||||||||||||
// stop all responses | ||||||||||||||||||||||
const couldStop = ChatControllerPool.hasPending(); | ||||||||||||||||||||||
const stopAll = () => ChatControllerPool.stopAll(); | ||||||||||||||||||||||
|
@@ -699,6 +707,9 @@ export function ChatActions(props: { | |||||||||||||||||||||
session.mask.modelConfig.providerName = | ||||||||||||||||||||||
providerName as ServiceProvider; | ||||||||||||||||||||||
session.mask.syncGlobalConfig = false; | ||||||||||||||||||||||
session.mask.modelConfig.enableNetWork = canUseNetWork(model) | ||||||||||||||||||||||
? session.mask.modelConfig.enableNetWork | ||||||||||||||||||||||
: false; | ||||||||||||||||||||||
}); | ||||||||||||||||||||||
if (providerName == "ByteDance") { | ||||||||||||||||||||||
const selectedModel = models.find( | ||||||||||||||||||||||
|
@@ -833,6 +844,16 @@ export function ChatActions(props: { | |||||||||||||||||||||
/> | ||||||||||||||||||||||
)} | ||||||||||||||||||||||
{!isMobileScreen && <MCPAction />} | ||||||||||||||||||||||
|
||||||||||||||||||||||
{canUseNetWork(currentModel) && ( | ||||||||||||||||||||||
<ChatAction | ||||||||||||||||||||||
onClick={nextNetWork} | ||||||||||||||||||||||
text={ | ||||||||||||||||||||||
Locale.Chat.InputActions.NetWork[enableNetWork ? "on" : "off"] | ||||||||||||||||||||||
} | ||||||||||||||||||||||
icon={<NetWorkIcon />} | ||||||||||||||||||||||
/> | ||||||||||||||||||||||
)} | ||||||||||||||||||||||
</> | ||||||||||||||||||||||
<div className={styles["chat-input-actions-end"]}> | ||||||||||||||||||||||
{config.realtimeConfig.enable && ( | ||||||||||||||||||||||
|
@@ -1286,50 +1307,86 @@ 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, | ||||||||||||||||||||||
)) { | ||||||||||||||||||||||
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); | ||||||||||||||||||||||
} | ||||||||||||||||||||||
} | ||||||||||||||||||||||
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.