mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
563dca82f4
* Add Codex prompt snapshots * Fix prompt snapshot scenario catalogs * Harden prompt snapshot drift check * Fix CLI compat build export * fix: keep codex snapshots out of core plugin surface * fix: harden prompt snapshot ci checks * fix: accept readonly web search onboarding scopes * fix: repair plugin sdk package boundary types * fix: clear prompt snapshot ci regressions * fix: clear latest main ci checks * fix: resolve latest main discord helper overlap * fix: refresh codex dynamic tool snapshots * fix: align prompt snapshot branch with latest ci * fix: isolate plugin auto enable tests * test: refresh prompt dynamic tool snapshots * fix: stabilize bundled channel auto enable * fix: clean stale prompt snapshots
110 lines
4.2 KiB
TypeScript
110 lines
4.2 KiB
TypeScript
import { Type } from "typebox";
|
|
import { getRuntimeConfig } from "../../config/config.js";
|
|
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
|
import { textToSpeech } from "../../tts/tts.js";
|
|
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
|
import type { AnyAgentTool } from "./common.js";
|
|
import { ToolInputError, readNumberParam, readStringParam } from "./common.js";
|
|
|
|
const TtsToolSchema = Type.Object({
|
|
text: Type.String({ description: "Text to convert to speech." }),
|
|
channel: Type.Optional(
|
|
Type.String({ description: "Optional channel id to pick output format." }),
|
|
),
|
|
timeoutMs: Type.Optional(
|
|
Type.Number({
|
|
description: "Optional provider request timeout in milliseconds.",
|
|
minimum: 1,
|
|
}),
|
|
),
|
|
});
|
|
|
|
function readTtsTimeoutMs(args: Record<string, unknown>): number | undefined {
|
|
const timeoutMs = readNumberParam(args, "timeoutMs", {
|
|
integer: true,
|
|
strict: true,
|
|
});
|
|
if (timeoutMs === undefined) {
|
|
return undefined;
|
|
}
|
|
if (timeoutMs <= 0) {
|
|
throw new ToolInputError("timeoutMs must be a positive integer in milliseconds.");
|
|
}
|
|
return timeoutMs;
|
|
}
|
|
|
|
/**
|
|
* Defuse reply-directive tokens inside spoken transcripts before they flow
|
|
* through tool-result content. When verbose tool output is enabled,
|
|
* `emitToolOutput` passes the content through `parseReplyDirectives`
|
|
* (`src/media/parse.ts` / `src/utils/directive-tags.ts`), and unfiltered
|
|
* `MEDIA:` or `[[audio_as_voice]]`-shaped tokens in the transcript would be
|
|
* rewritten into actual media URLs and audio-as-voice flags. Insert a
|
|
* zero-width word joiner so the regex patterns stop matching without
|
|
* changing the visible text.
|
|
*/
|
|
function sanitizeTranscriptForToolContent(text: string): string {
|
|
return text
|
|
.replace(/^([^\S\r\n]*)MEDIA:/gim, "$1\u2060MEDIA:")
|
|
.replace(/\[\[/g, "[\u2060[")
|
|
.replace(/^([ \t]*)(`{3,})/gm, (_match, indent: string, fence: string) => {
|
|
const [first = "", ...rest] = fence;
|
|
return `${indent}${first}\u2060${rest.join("")}`;
|
|
});
|
|
}
|
|
|
|
export function createTtsTool(opts?: {
|
|
config?: OpenClawConfig;
|
|
agentChannel?: GatewayMessageChannel;
|
|
agentId?: string;
|
|
agentAccountId?: string;
|
|
}): AnyAgentTool {
|
|
return {
|
|
label: "TTS",
|
|
name: "tts",
|
|
displaySummary: "Convert text to speech and return audio.",
|
|
description:
|
|
"Use only for explicit audio intent (audio, voice, speech, TTS) or active TTS config. Never use for ordinary text replies. " +
|
|
"Audio is delivered automatically from the tool result. After a successful call, follow the current conversation's reply instructions and avoid sending a duplicate text/audio response.",
|
|
parameters: TtsToolSchema,
|
|
execute: async (_toolCallId, args) => {
|
|
const params = args as Record<string, unknown>;
|
|
const text = readStringParam(params, "text", { required: true });
|
|
const channel = readStringParam(params, "channel");
|
|
const timeoutMs = readTtsTimeoutMs(params);
|
|
const cfg = opts?.config ?? getRuntimeConfig();
|
|
const result = await textToSpeech({
|
|
text,
|
|
cfg,
|
|
channel: channel ?? opts?.agentChannel,
|
|
timeoutMs,
|
|
agentId: opts?.agentId,
|
|
accountId: opts?.agentAccountId,
|
|
});
|
|
|
|
if (result.success && result.audioPath) {
|
|
// Preserve the spoken text in the tool result content so the session
|
|
// transcript retains what was said across turns. The audio itself is
|
|
// still delivered via details.media. Sanitize first so a crafted
|
|
// utterance cannot inject reply directives when the tool output is
|
|
// rendered in verbose mode.
|
|
return {
|
|
content: [{ type: "text", text: `(spoken) ${sanitizeTranscriptForToolContent(text)}` }],
|
|
details: {
|
|
audioPath: result.audioPath,
|
|
provider: result.provider,
|
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
media: {
|
|
mediaUrl: result.audioPath,
|
|
trustedLocalMedia: true,
|
|
...(result.audioAsVoice || result.voiceCompatible ? { audioAsVoice: true } : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
throw new Error(result.error ?? "TTS conversion failed");
|
|
},
|
|
};
|
|
}
|