mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
improve: reduce agent startup cost from TTS imports (#109344)
* perf(tts): split lightweight settings imports * fix(tts): drop redundant internal exports * fix(tts): remove unused settings type import * docs(sdk): refresh speech settings API baseline * fix(tts): align SDK surface gates * fix(tts): ignore non-object preference roots
This commit is contained in:
committed by
GitHub
parent
a70d583f24
commit
6d20432a29
@@ -1,2 +1,2 @@
|
||||
739fe620027bad069221aef1e6ec68bef0448c31a1573f70802a461b212c3d7d plugin-sdk-api-baseline.json
|
||||
08eb0ae2bd2c3b32e04661cde03260aa97462ba64a4944307a0bdf98f4f98a75 plugin-sdk-api-baseline.jsonl
|
||||
336b76c2642b15524c672158d7209dda8f22efe4b5ce88f5e3ee6674dc293882 plugin-sdk-api-baseline.json
|
||||
26fd03f65ea0893b3592f3b070d9c5f2b1ff7d2a89271cdf0b6579de20c569c9 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -502,6 +502,7 @@ SDK.
|
||||
| `plugin-sdk/text-chunking` | Text chunking helpers | Outbound text and offset-preserving range chunking helpers |
|
||||
| `plugin-sdk/speech` | Speech helpers | Speech provider types plus provider-facing directive, registry, validation helpers, and OpenAI-compatible TTS builder |
|
||||
| `plugin-sdk/speech-core` | Shared speech core | Speech provider types, registry, directives, normalization |
|
||||
| `plugin-sdk/speech-settings` | Speech settings | Lightweight TTS config resolution and normalization primitives without provider registries or synthesis runtime |
|
||||
| `plugin-sdk/realtime-transcription` | Realtime transcription helpers | Provider types, registry helpers, and shared WebSocket session helper |
|
||||
| `plugin-sdk/realtime-voice` | Realtime voice helpers | Provider types, registry/resolution helpers, bridge session helpers, shared agent talk-back queues, active-run voice control, transcript/event health, echo suppression, consult question matching, forced-consult coordination, turn-context tracking, output activity tracking, and fast context consult helpers |
|
||||
| `plugin-sdk/image-generation` | Image-generation helpers | Image generation provider types plus image asset/data URL helpers and the OpenAI-compatible image provider builder |
|
||||
|
||||
@@ -357,6 +357,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/text-chunking` | Outbound text and offset-preserving range chunking, markdown chunking/render helpers, quote-aware HTML tag tokenization, markdown table conversion, directive-tag stripping, and safe-text utilities |
|
||||
| `plugin-sdk/speech` | Speech provider types plus provider-facing directive, registry, validation, OpenAI-compatible TTS builder, and speech helper exports |
|
||||
| `plugin-sdk/speech-core` | Shared speech provider types, registry, directive, normalization, and speech helper exports |
|
||||
| `plugin-sdk/speech-settings` | Lightweight TTS config resolution and normalization primitives without provider registries or synthesis runtime |
|
||||
| `plugin-sdk/realtime-transcription` | Realtime transcription provider types, registry helpers, and shared WebSocket session helper |
|
||||
| `plugin-sdk/realtime-bootstrap-context` | Realtime profile bootstrap helper for bounded `IDENTITY.md`, `USER.md`, and `SOUL.md` context injection |
|
||||
| `plugin-sdk/realtime-voice` | Realtime voice provider types, registry helpers, and shared realtime voice behavior helpers, including output activity tracking |
|
||||
|
||||
@@ -573,6 +573,10 @@
|
||||
"types": "./dist/plugin-sdk/speech-core.d.ts",
|
||||
"default": "./dist/plugin-sdk/speech-core.js"
|
||||
},
|
||||
"./plugin-sdk/speech-settings": {
|
||||
"types": "./dist/plugin-sdk/speech-settings.d.ts",
|
||||
"default": "./dist/plugin-sdk/speech-settings.js"
|
||||
},
|
||||
"./plugin-sdk/tts-runtime": {
|
||||
"types": "./dist/plugin-sdk/tts-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/tts-runtime.js"
|
||||
|
||||
@@ -2,29 +2,35 @@
|
||||
// helpers used by speech-capable plugins.
|
||||
export {
|
||||
buildTtsSystemPromptHint,
|
||||
getLastTtsAttempt,
|
||||
getResolvedSpeechProviderConfig,
|
||||
getTtsMaxLength,
|
||||
getTtsPersona,
|
||||
getTtsProvider,
|
||||
isSummarizationEnabled,
|
||||
isTtsEnabled,
|
||||
isTtsProviderConfigured,
|
||||
listSpeechVoices,
|
||||
listTtsPersonas,
|
||||
maybeApplyTtsToPayload,
|
||||
resolveExplicitTtsOverrides,
|
||||
resolveTtsAutoMode,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
resolveTtsProviderOrder,
|
||||
setLastTtsAttempt,
|
||||
type ResolvedTtsConfig,
|
||||
type ResolvedTtsModelOverrides,
|
||||
} from "./src/tts-settings.js";
|
||||
export {
|
||||
setSummarizationEnabled,
|
||||
setTtsAutoMode,
|
||||
setTtsEnabled,
|
||||
setTtsMaxLength,
|
||||
setTtsPersona,
|
||||
setTtsProvider,
|
||||
} from "./src/tts-settings-writes.js";
|
||||
export {
|
||||
getLastTtsAttempt,
|
||||
getResolvedSpeechProviderConfig,
|
||||
getTtsProvider,
|
||||
isTtsProviderConfigured,
|
||||
listSpeechVoices,
|
||||
maybeApplyTtsToPayload,
|
||||
resolveExplicitTtsOverrides,
|
||||
resolveTtsProviderOrder,
|
||||
setLastTtsAttempt,
|
||||
synthesizeSpeech,
|
||||
streamSpeech,
|
||||
textToSpeech,
|
||||
@@ -32,8 +38,6 @@ export {
|
||||
textToSpeechTelephony,
|
||||
testApi as _test,
|
||||
testApi,
|
||||
type ResolvedTtsConfig,
|
||||
type ResolvedTtsModelOverrides,
|
||||
type TtsDirectiveOverrides,
|
||||
type TtsDirectiveParseResult,
|
||||
type TtsResult,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// TTS preference mutations stay off the agent prompt's read-only import path.
|
||||
import path from "node:path";
|
||||
import type { TtsAutoMode, TtsProvider } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { privateFileStoreSync } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { canonicalizeSpeechProviderId } from "openclaw/plugin-sdk/speech-core";
|
||||
import { normalizeTtsPersonaId, readTtsPrefs, type TtsUserPrefs } from "./tts-settings.js";
|
||||
|
||||
function updateTtsPrefs(prefsPath: string, update: (prefs: TtsUserPrefs) => void): void {
|
||||
const prefs = readTtsPrefs(prefsPath);
|
||||
update(prefs);
|
||||
privateFileStoreSync(path.dirname(prefsPath)).writeText(
|
||||
path.basename(prefsPath),
|
||||
JSON.stringify(prefs, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
export function setTtsAutoMode(prefsPath: string, mode: TtsAutoMode): void {
|
||||
updateTtsPrefs(prefsPath, (prefs) => {
|
||||
const next = { ...prefs.tts };
|
||||
delete next.enabled;
|
||||
next.auto = mode;
|
||||
prefs.tts = next;
|
||||
});
|
||||
}
|
||||
|
||||
export function setTtsEnabled(prefsPath: string, enabled: boolean): void {
|
||||
setTtsAutoMode(prefsPath, enabled ? "always" : "off");
|
||||
}
|
||||
|
||||
export function setTtsPersona(prefsPath: string, persona: string | null | undefined): void {
|
||||
updateTtsPrefs(prefsPath, (prefs) => {
|
||||
const next = { ...prefs.tts };
|
||||
next.persona = normalizeTtsPersonaId(persona) ?? null;
|
||||
prefs.tts = next;
|
||||
});
|
||||
}
|
||||
|
||||
export function setTtsProvider(prefsPath: string, provider: TtsProvider): void {
|
||||
updateTtsPrefs(prefsPath, (prefs) => {
|
||||
prefs.tts = { ...prefs.tts, provider: canonicalizeSpeechProviderId(provider) ?? provider };
|
||||
});
|
||||
}
|
||||
|
||||
export function setTtsMaxLength(prefsPath: string, maxLength: number): void {
|
||||
updateTtsPrefs(prefsPath, (prefs) => {
|
||||
prefs.tts = { ...prefs.tts, maxLength };
|
||||
});
|
||||
}
|
||||
|
||||
export function setSummarizationEnabled(prefsPath: string, enabled: boolean): void {
|
||||
updateTtsPrefs(prefsPath, (prefs) => {
|
||||
prefs.tts = { ...prefs.tts, summarize: enabled };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// Lightweight TTS settings resolution shared by agent prompts, status, and speech runtime.
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
ResolvedTtsPersona,
|
||||
TtsAutoMode,
|
||||
TtsConfig,
|
||||
TtsModelOverrideConfig,
|
||||
TtsProvider,
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
getRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
selectApplicableRuntimeConfig,
|
||||
} from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import type { SpeechProviderConfig } from "openclaw/plugin-sdk/speech-core";
|
||||
import {
|
||||
normalizeSpeechProviderId,
|
||||
normalizeTtsAutoMode,
|
||||
resolveEffectiveTtsConfig,
|
||||
type ResolvedTtsConfig,
|
||||
type ResolvedTtsModelOverrides,
|
||||
type TtsConfigResolutionContext,
|
||||
} from "openclaw/plugin-sdk/speech-settings";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveConfigDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { withSpeakerSelectionCompat } from "../speaker.js";
|
||||
|
||||
export type { ResolvedTtsConfig, ResolvedTtsModelOverrides };
|
||||
|
||||
export const DEFAULT_TTS_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_TTS_MAX_LENGTH = 1500;
|
||||
const DEFAULT_TTS_SUMMARIZE = true;
|
||||
const DEFAULT_MAX_TEXT_LENGTH = 4096;
|
||||
|
||||
export type TtsUserPrefs = {
|
||||
tts?: {
|
||||
auto?: TtsAutoMode;
|
||||
enabled?: boolean;
|
||||
provider?: TtsProvider;
|
||||
persona?: string | null;
|
||||
maxLength?: number;
|
||||
summarize?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveConfiguredTtsAutoMode(raw: TtsConfig): TtsAutoMode {
|
||||
return normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off");
|
||||
}
|
||||
|
||||
export function normalizeConfiguredSpeechProviderId(
|
||||
providerId: string | undefined,
|
||||
): TtsProvider | undefined {
|
||||
const normalized = normalizeSpeechProviderId(providerId);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized === "edge" ? "microsoft" : normalized;
|
||||
}
|
||||
|
||||
export function normalizeTtsPersonaId(personaId: string | null | undefined): string | undefined {
|
||||
return normalizeOptionalLowercaseString(personaId ?? undefined);
|
||||
}
|
||||
|
||||
function resolveTtsPrefsPathValue(prefsPath: string | undefined): string {
|
||||
if (prefsPath?.trim()) {
|
||||
return resolveUserPath(prefsPath.trim());
|
||||
}
|
||||
const envPath = process.env.OPENCLAW_TTS_PREFS?.trim();
|
||||
if (envPath) {
|
||||
return resolveUserPath(envPath);
|
||||
}
|
||||
return path.join(resolveConfigDir(process.env), "settings", "tts.json");
|
||||
}
|
||||
|
||||
export function resolveModelOverridePolicy(
|
||||
overrides: TtsModelOverrideConfig | undefined,
|
||||
): ResolvedTtsModelOverrides {
|
||||
const enabled = overrides?.enabled ?? true;
|
||||
if (!enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
allowText: false,
|
||||
allowProvider: false,
|
||||
allowVoice: false,
|
||||
allowModelId: false,
|
||||
allowVoiceSettings: false,
|
||||
allowNormalization: false,
|
||||
allowSeed: false,
|
||||
};
|
||||
}
|
||||
const allow = (value: boolean | undefined, defaultValue = true) => value ?? defaultValue;
|
||||
return {
|
||||
enabled: true,
|
||||
allowText: allow(overrides?.allowText),
|
||||
allowProvider: allow(overrides?.allowProvider, false),
|
||||
allowVoice: allow(overrides?.allowVoice),
|
||||
allowModelId: allow(overrides?.allowModelId),
|
||||
allowVoiceSettings: allow(overrides?.allowVoiceSettings),
|
||||
allowNormalization: allow(overrides?.allowNormalization),
|
||||
allowSeed: allow(overrides?.allowSeed),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTtsRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return (
|
||||
selectApplicableRuntimeConfig({
|
||||
inputConfig: cfg,
|
||||
runtimeConfig: getRuntimeConfigSnapshot(),
|
||||
runtimeSourceConfig: getRuntimeConfigSourceSnapshot(),
|
||||
}) ?? cfg
|
||||
);
|
||||
}
|
||||
|
||||
export function asProviderConfig(value: unknown): SpeechProviderConfig {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? withSpeakerSelectionCompat(value as SpeechProviderConfig)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function asProviderConfigMap(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function hasOwnProperty(value: object, key: string): boolean {
|
||||
return Object.hasOwn(value, key);
|
||||
}
|
||||
|
||||
function normalizeProviderConfigMap(
|
||||
value: unknown,
|
||||
): Record<string, SpeechProviderConfig> | undefined {
|
||||
const rawMap = asProviderConfigMap(value);
|
||||
if (Object.keys(rawMap).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const next: Record<string, SpeechProviderConfig> = {};
|
||||
for (const [providerId, providerConfig] of Object.entries(rawMap)) {
|
||||
const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId;
|
||||
next[normalized] = asProviderConfig(providerConfig);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function collectTtsPersonas(raw: TtsConfig): Record<string, ResolvedTtsPersona> {
|
||||
const rawPersonas = asProviderConfigMap(raw.personas);
|
||||
const personas: Record<string, ResolvedTtsPersona> = {};
|
||||
for (const [id, value] of Object.entries(rawPersonas)) {
|
||||
const normalizedId = normalizeTtsPersonaId(id);
|
||||
if (!normalizedId || typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
const persona = value as Omit<ResolvedTtsPersona, "id">;
|
||||
personas[normalizedId] = {
|
||||
...persona,
|
||||
id: normalizedId,
|
||||
provider: normalizeConfiguredSpeechProviderId(persona.provider) ?? persona.provider,
|
||||
providers: normalizeProviderConfigMap(persona.providers),
|
||||
};
|
||||
}
|
||||
return personas;
|
||||
}
|
||||
|
||||
function collectDirectProviderConfigEntries(raw: TtsConfig): Record<string, SpeechProviderConfig> {
|
||||
const entries: Record<string, SpeechProviderConfig> = {};
|
||||
const rawProviders = asProviderConfigMap(raw.providers);
|
||||
for (const [providerId, value] of Object.entries(rawProviders)) {
|
||||
const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId;
|
||||
entries[normalized] = asProviderConfig(value);
|
||||
}
|
||||
const reservedKeys = new Set([
|
||||
"auto",
|
||||
"enabled",
|
||||
"maxTextLength",
|
||||
"mode",
|
||||
"modelOverrides",
|
||||
"persona",
|
||||
"personas",
|
||||
"prefsPath",
|
||||
"provider",
|
||||
"providers",
|
||||
"summaryModel",
|
||||
"timeoutMs",
|
||||
]);
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (reservedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeConfiguredSpeechProviderId(key) ?? key;
|
||||
entries[normalized] ??= asProviderConfig(value);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function resolveTtsConfig(
|
||||
cfgInput: OpenClawConfig,
|
||||
contextOrAgentId?: string | TtsConfigResolutionContext,
|
||||
): ResolvedTtsConfig {
|
||||
const cfg = resolveTtsRuntimeConfig(cfgInput);
|
||||
const raw: TtsConfig = resolveEffectiveTtsConfig(cfg, contextOrAgentId);
|
||||
const providerSource = raw.provider ? "config" : "default";
|
||||
const timeoutMs = raw.timeoutMs ?? DEFAULT_TTS_TIMEOUT_MS;
|
||||
const timeoutMsSource = raw.timeoutMs === undefined ? "default" : "config";
|
||||
return {
|
||||
auto: resolveConfiguredTtsAutoMode(raw),
|
||||
mode: raw.mode ?? "final",
|
||||
provider:
|
||||
normalizeConfiguredSpeechProviderId(raw.provider) ??
|
||||
(providerSource === "config" ? (normalizeOptionalLowercaseString(raw.provider) ?? "") : ""),
|
||||
providerSource,
|
||||
persona: normalizeTtsPersonaId(raw.persona),
|
||||
personas: collectTtsPersonas(raw),
|
||||
summaryModel: normalizeOptionalString(raw.summaryModel),
|
||||
modelOverrides: resolveModelOverridePolicy(raw.modelOverrides),
|
||||
providerConfigs: collectDirectProviderConfigEntries(raw),
|
||||
prefsPath: raw.prefsPath,
|
||||
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
|
||||
timeoutMs,
|
||||
timeoutMsSource,
|
||||
rawConfig: raw,
|
||||
sourceConfig: cfg,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTtsPrefsPath(config: ResolvedTtsConfig): string {
|
||||
return resolveTtsPrefsPathValue(config.prefsPath);
|
||||
}
|
||||
|
||||
export function readTtsPrefs(prefsPath: string): TtsUserPrefs {
|
||||
try {
|
||||
if (!existsSync(prefsPath)) {
|
||||
return {};
|
||||
}
|
||||
const parsed: unknown = JSON.parse(readFileSync(prefsPath, "utf8"));
|
||||
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as TtsUserPrefs)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined {
|
||||
const auto = normalizeTtsAutoMode(prefs.tts?.auto);
|
||||
if (auto) {
|
||||
return auto;
|
||||
}
|
||||
if (typeof prefs.tts?.enabled === "boolean") {
|
||||
return prefs.tts.enabled ? "always" : "off";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveTtsAutoMode(params: {
|
||||
config: ResolvedTtsConfig;
|
||||
prefsPath: string;
|
||||
sessionAuto?: string;
|
||||
}): TtsAutoMode {
|
||||
const sessionAuto = normalizeTtsAutoMode(params.sessionAuto);
|
||||
if (sessionAuto) {
|
||||
return sessionAuto;
|
||||
}
|
||||
return resolveTtsAutoModeFromPrefs(readTtsPrefs(params.prefsPath)) ?? params.config.auto;
|
||||
}
|
||||
|
||||
function resolveTtsPersonaIdFromPrefs(
|
||||
config: ResolvedTtsConfig,
|
||||
prefs: TtsUserPrefs,
|
||||
): string | undefined {
|
||||
if (prefs.tts && hasOwnProperty(prefs.tts, "persona")) {
|
||||
return normalizeTtsPersonaId(prefs.tts.persona);
|
||||
}
|
||||
return normalizeTtsPersonaId(config.persona);
|
||||
}
|
||||
|
||||
export function resolveTtsPersonaFromPrefs(
|
||||
config: ResolvedTtsConfig,
|
||||
prefs: TtsUserPrefs,
|
||||
): ResolvedTtsPersona | undefined {
|
||||
const personaId = resolveTtsPersonaIdFromPrefs(config, prefs);
|
||||
return personaId ? config.personas[personaId] : undefined;
|
||||
}
|
||||
|
||||
type ResolvedTtsSettingsSnapshot = {
|
||||
autoMode: TtsAutoMode;
|
||||
config: ResolvedTtsConfig;
|
||||
maxLength: number;
|
||||
persona?: ResolvedTtsPersona;
|
||||
personaId?: string;
|
||||
preferredProvider?: TtsProvider;
|
||||
prefsPath: string;
|
||||
summarize: boolean;
|
||||
};
|
||||
|
||||
export function resolveTtsSettingsSnapshot(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionAuto?: string;
|
||||
agentId?: string;
|
||||
channelId?: string;
|
||||
accountId?: string;
|
||||
}): ResolvedTtsSettingsSnapshot {
|
||||
const config = resolveTtsConfig(params.cfg, {
|
||||
agentId: params.agentId,
|
||||
channelId: params.channelId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
const prefs = readTtsPrefs(prefsPath);
|
||||
const personaId = resolveTtsPersonaIdFromPrefs(config, prefs);
|
||||
const persona = personaId ? config.personas[personaId] : undefined;
|
||||
const preferredProvider =
|
||||
normalizeConfiguredSpeechProviderId(prefs.tts?.provider) ??
|
||||
normalizeConfiguredSpeechProviderId(persona?.provider) ??
|
||||
(config.providerSource === "config"
|
||||
? (normalizeConfiguredSpeechProviderId(config.provider) ?? config.provider)
|
||||
: undefined);
|
||||
return {
|
||||
autoMode:
|
||||
normalizeTtsAutoMode(params.sessionAuto) ?? resolveTtsAutoModeFromPrefs(prefs) ?? config.auto,
|
||||
config,
|
||||
maxLength: prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH,
|
||||
...(persona ? { persona } : {}),
|
||||
...(personaId ? { personaId } : {}),
|
||||
...(preferredProvider ? { preferredProvider } : {}),
|
||||
prefsPath,
|
||||
summarize: prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTtsSystemPromptHint(
|
||||
cfg: OpenClawConfig,
|
||||
agentId?: string,
|
||||
): string | undefined {
|
||||
const settings = resolveTtsSettingsSnapshot({ cfg, agentId });
|
||||
if (settings.autoMode === "off") {
|
||||
return undefined;
|
||||
}
|
||||
const autoHint =
|
||||
settings.autoMode === "inbound"
|
||||
? "Only use TTS when the user's last message includes audio/voice."
|
||||
: settings.autoMode === "tagged"
|
||||
? "Only use TTS when you include [[tts:key=value]] directives or a [[tts:text]]...[[/tts:text]] block."
|
||||
: undefined;
|
||||
return [
|
||||
"Voice (TTS) is enabled.",
|
||||
autoHint,
|
||||
settings.persona
|
||||
? `Active TTS persona: ${settings.persona.label ?? settings.persona.id}${settings.persona.description ? ` - ${settings.persona.description}` : ""}.`
|
||||
: undefined,
|
||||
`Keep spoken text ≤${settings.maxLength} chars to avoid auto-summary (summary ${settings.summarize ? "on" : "off"}).`,
|
||||
"If workspace context (especially MEMORY.md) tells you not to use [[tts:...]] or to use a local/non-tagged voice workflow, follow that workspace instruction instead.",
|
||||
"Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function isTtsEnabled(
|
||||
config: ResolvedTtsConfig,
|
||||
prefsPath: string,
|
||||
sessionAuto?: string,
|
||||
): boolean {
|
||||
return resolveTtsAutoMode({ config, prefsPath, sessionAuto }) !== "off";
|
||||
}
|
||||
|
||||
export function getTtsPersona(
|
||||
config: ResolvedTtsConfig,
|
||||
prefsPath: string,
|
||||
): ResolvedTtsPersona | undefined {
|
||||
return resolveTtsPersonaFromPrefs(config, readTtsPrefs(prefsPath));
|
||||
}
|
||||
|
||||
export function listTtsPersonas(config: ResolvedTtsConfig): ResolvedTtsPersona[] {
|
||||
return Object.values(config.personas).toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export function getTtsMaxLength(prefsPath: string): number {
|
||||
return readTtsPrefs(prefsPath).tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH;
|
||||
}
|
||||
|
||||
export function isSummarizationEnabled(prefsPath: string): boolean {
|
||||
return readTtsPrefs(prefsPath).tts?.summarize ?? DEFAULT_TTS_SUMMARIZE;
|
||||
}
|
||||
@@ -122,7 +122,7 @@ const {
|
||||
setTtsMaxLength,
|
||||
synthesizeSpeech,
|
||||
textToSpeechTelephony,
|
||||
} = await import("./tts.js");
|
||||
} = await import("../runtime-api.js");
|
||||
|
||||
const nativeVoiceNoteChannels = ["discord", "feishu", "matrix", "telegram", "whatsapp"] as const;
|
||||
|
||||
|
||||
+22
-432
@@ -1,13 +1,9 @@
|
||||
// Speech Core module implements tts behavior.
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveChannelTtsVoiceDelivery } from "openclaw/plugin-sdk/channel-targets";
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
ResolvedTtsPersona,
|
||||
TtsAutoMode,
|
||||
TtsConfig,
|
||||
TtsModelOverrideConfig,
|
||||
TtsProvider,
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
@@ -19,24 +15,14 @@ import {
|
||||
resolveSendableOutboundReplyParts,
|
||||
type ReplyPayload,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
getRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
selectApplicableRuntimeConfig,
|
||||
} from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { isVerbose, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { tempWorkspaceSync, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
|
||||
import { privateFileStoreSync } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
canonicalizeSpeechProviderId,
|
||||
getSpeechProvider,
|
||||
listSpeechProviders,
|
||||
normalizeSpeechProviderId,
|
||||
normalizeTtsAutoMode,
|
||||
parseTtsDirectives,
|
||||
resolveEffectiveTtsConfig,
|
||||
type ResolvedTtsConfig,
|
||||
type ResolvedTtsModelOverrides,
|
||||
scheduleCleanup,
|
||||
summarizeText,
|
||||
type SpeechProviderPlugin,
|
||||
@@ -45,19 +31,13 @@ import {
|
||||
type SpeechVoiceOption,
|
||||
type TtsDirectiveOverrides,
|
||||
type TtsDirectiveParseResult,
|
||||
type TtsConfigResolutionContext,
|
||||
} from "openclaw/plugin-sdk/speech-core";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
|
||||
import {
|
||||
resolveConfigDir,
|
||||
resolveUserPath,
|
||||
truncateUtf16Safe,
|
||||
} from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { withSpeakerSelectionCompat } from "../speaker.js";
|
||||
import {
|
||||
resolvePrimaryVoiceProviderCandidate,
|
||||
@@ -69,18 +49,26 @@ import {
|
||||
type VoiceModelProvider,
|
||||
type VoiceProviderCandidate,
|
||||
} from "../voice-models.js";
|
||||
import {
|
||||
DEFAULT_TTS_TIMEOUT_MS,
|
||||
asProviderConfig,
|
||||
asProviderConfigMap,
|
||||
getTtsMaxLength,
|
||||
getTtsPersona,
|
||||
hasOwnProperty,
|
||||
isSummarizationEnabled,
|
||||
normalizeConfiguredSpeechProviderId,
|
||||
readTtsPrefs as readPrefs,
|
||||
resolveModelOverridePolicy,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPersonaFromPrefs,
|
||||
resolveTtsPrefsPath,
|
||||
resolveTtsRuntimeConfig,
|
||||
resolveTtsSettingsSnapshot,
|
||||
type ResolvedTtsConfig,
|
||||
} from "./tts-settings.js";
|
||||
|
||||
export type {
|
||||
ResolvedTtsConfig,
|
||||
ResolvedTtsModelOverrides,
|
||||
TtsDirectiveOverrides,
|
||||
TtsDirectiveParseResult,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_TTS_MAX_LENGTH = 1500;
|
||||
const DEFAULT_TTS_SUMMARIZE = true;
|
||||
const DEFAULT_MAX_TEXT_LENGTH = 4096;
|
||||
export type { TtsDirectiveOverrides, TtsDirectiveParseResult };
|
||||
|
||||
function resolvePositiveTimeoutMs(timeoutMs: number | undefined): number | undefined {
|
||||
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
@@ -97,22 +85,11 @@ function resolveSpeechProviderTimeoutMs(params: {
|
||||
return resolvePositiveTimeoutMs(params.timeoutMs) ?? params.config.timeoutMs;
|
||||
}
|
||||
if (params.config.timeoutMsSource !== "default") {
|
||||
return resolvePositiveTimeoutMs(params.config.timeoutMs) ?? DEFAULT_TIMEOUT_MS;
|
||||
return resolvePositiveTimeoutMs(params.config.timeoutMs) ?? DEFAULT_TTS_TIMEOUT_MS;
|
||||
}
|
||||
return resolvePositiveTimeoutMs(params.provider.defaultTimeoutMs) ?? params.config.timeoutMs;
|
||||
}
|
||||
|
||||
type TtsUserPrefs = {
|
||||
tts?: {
|
||||
auto?: TtsAutoMode;
|
||||
enabled?: boolean;
|
||||
provider?: TtsProvider;
|
||||
persona?: string | null;
|
||||
maxLength?: number;
|
||||
summarize?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type TtsAttemptReasonCode =
|
||||
| "success"
|
||||
| "no_provider_registered"
|
||||
@@ -219,64 +196,6 @@ type TtsStatusEntry = {
|
||||
|
||||
let lastTtsAttempt: TtsStatusEntry | undefined;
|
||||
|
||||
function resolveConfiguredTtsAutoMode(raw: TtsConfig): TtsAutoMode {
|
||||
return normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off");
|
||||
}
|
||||
|
||||
function normalizeConfiguredSpeechProviderId(
|
||||
providerId: string | undefined,
|
||||
): TtsProvider | undefined {
|
||||
const normalized = normalizeSpeechProviderId(providerId);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized === "edge" ? "microsoft" : normalized;
|
||||
}
|
||||
|
||||
function normalizeTtsPersonaId(personaId: string | null | undefined): string | undefined {
|
||||
return normalizeOptionalLowercaseString(personaId ?? undefined);
|
||||
}
|
||||
|
||||
function resolveTtsPrefsPathValue(prefsPath: string | undefined): string {
|
||||
if (prefsPath?.trim()) {
|
||||
return resolveUserPath(prefsPath.trim());
|
||||
}
|
||||
const envPath = process.env.OPENCLAW_TTS_PREFS?.trim();
|
||||
if (envPath) {
|
||||
return resolveUserPath(envPath);
|
||||
}
|
||||
return path.join(resolveConfigDir(process.env), "settings", "tts.json");
|
||||
}
|
||||
|
||||
function resolveModelOverridePolicy(
|
||||
overrides: TtsModelOverrideConfig | undefined,
|
||||
): ResolvedTtsModelOverrides {
|
||||
const enabled = overrides?.enabled ?? true;
|
||||
if (!enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
allowText: false,
|
||||
allowProvider: false,
|
||||
allowVoice: false,
|
||||
allowModelId: false,
|
||||
allowVoiceSettings: false,
|
||||
allowNormalization: false,
|
||||
allowSeed: false,
|
||||
};
|
||||
}
|
||||
const allow = (value: boolean | undefined, defaultValue = true) => value ?? defaultValue;
|
||||
return {
|
||||
enabled: true,
|
||||
allowText: allow(overrides?.allowText),
|
||||
allowProvider: allow(overrides?.allowProvider, false),
|
||||
allowVoice: allow(overrides?.allowVoice),
|
||||
allowModelId: allow(overrides?.allowModelId),
|
||||
allowVoiceSettings: allow(overrides?.allowVoiceSettings),
|
||||
allowNormalization: allow(overrides?.allowNormalization),
|
||||
allowSeed: allow(overrides?.allowSeed),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredSpeechVoiceModelRefs(cfg: OpenClawConfig | undefined): VoiceModelRef[] {
|
||||
const effectiveCfg = cfg ? resolveTtsRuntimeConfig(cfg) : undefined;
|
||||
return resolveSupportedVoiceModelRefs({
|
||||
@@ -344,66 +263,6 @@ function sortSpeechProvidersForAutoSelection(cfg?: OpenClawConfig) {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTtsRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return (
|
||||
selectApplicableRuntimeConfig({
|
||||
inputConfig: cfg,
|
||||
runtimeConfig: getRuntimeConfigSnapshot(),
|
||||
runtimeSourceConfig: getRuntimeConfigSourceSnapshot(),
|
||||
}) ?? cfg
|
||||
);
|
||||
}
|
||||
|
||||
function asProviderConfig(value: unknown): SpeechProviderConfig {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as SpeechProviderConfig)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asProviderConfigMap(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function hasOwnProperty(value: object, key: string): boolean {
|
||||
return Object.hasOwn(value, key);
|
||||
}
|
||||
|
||||
function normalizeProviderConfigMap(
|
||||
value: unknown,
|
||||
): Record<string, SpeechProviderConfig> | undefined {
|
||||
const rawMap = asProviderConfigMap(value);
|
||||
if (Object.keys(rawMap).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const next: Record<string, SpeechProviderConfig> = {};
|
||||
for (const [providerId, providerConfig] of Object.entries(rawMap)) {
|
||||
const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId;
|
||||
next[normalized] = withSpeakerSelectionCompat(asProviderConfig(providerConfig));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function collectTtsPersonas(raw: TtsConfig): Record<string, ResolvedTtsPersona> {
|
||||
const rawPersonas = asProviderConfigMap(raw.personas);
|
||||
const personas: Record<string, ResolvedTtsPersona> = {};
|
||||
for (const [id, value] of Object.entries(rawPersonas)) {
|
||||
const normalizedId = normalizeTtsPersonaId(id);
|
||||
if (!normalizedId || typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
const persona = value as Omit<ResolvedTtsPersona, "id">;
|
||||
personas[normalizedId] = {
|
||||
...persona,
|
||||
id: normalizedId,
|
||||
provider: normalizeConfiguredSpeechProviderId(persona.provider) ?? persona.provider,
|
||||
providers: normalizeProviderConfigMap(persona.providers),
|
||||
};
|
||||
}
|
||||
return personas;
|
||||
}
|
||||
|
||||
function resolvePersonaProviderConfig(
|
||||
persona: ResolvedTtsPersona | undefined,
|
||||
providerId: string,
|
||||
@@ -538,40 +397,6 @@ function resolveLazyProviderConfig(
|
||||
return next;
|
||||
}
|
||||
|
||||
function collectDirectProviderConfigEntries(raw: TtsConfig): Record<string, SpeechProviderConfig> {
|
||||
const entries: Record<string, SpeechProviderConfig> = {};
|
||||
const rawProviders = asProviderConfigMap(raw.providers);
|
||||
for (const [providerId, value] of Object.entries(rawProviders)) {
|
||||
const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId;
|
||||
entries[normalized] = withSpeakerSelectionCompat(asProviderConfig(value));
|
||||
}
|
||||
const reservedKeys = new Set([
|
||||
"auto",
|
||||
"enabled",
|
||||
"maxTextLength",
|
||||
"mode",
|
||||
"modelOverrides",
|
||||
"persona",
|
||||
"personas",
|
||||
"prefsPath",
|
||||
"provider",
|
||||
"providers",
|
||||
"summaryModel",
|
||||
"timeoutMs",
|
||||
]);
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (reservedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeConfiguredSpeechProviderId(key) ?? key;
|
||||
entries[normalized] ??= withSpeakerSelectionCompat(asProviderConfig(value));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function getResolvedSpeechProviderConfig(
|
||||
config: ResolvedTtsConfig,
|
||||
providerId: string,
|
||||
@@ -602,176 +427,6 @@ function getResolvedSpeechProviderConfigForVoiceModel(params: {
|
||||
return resolveLazyProviderConfig(params.config, canonical, effectiveCfg, params.voiceModel);
|
||||
}
|
||||
|
||||
export function resolveTtsConfig(
|
||||
cfgInput: OpenClawConfig,
|
||||
contextOrAgentId?: string | TtsConfigResolutionContext,
|
||||
): ResolvedTtsConfig {
|
||||
let cfg = cfgInput;
|
||||
cfg = resolveTtsRuntimeConfig(cfg);
|
||||
const raw: TtsConfig = resolveEffectiveTtsConfig(cfg, contextOrAgentId);
|
||||
const providerSource = raw.provider ? "config" : "default";
|
||||
const timeoutMs = raw.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const timeoutMsSource = raw.timeoutMs === undefined ? "default" : "config";
|
||||
const auto = resolveConfiguredTtsAutoMode(raw);
|
||||
const persona = normalizeTtsPersonaId(raw.persona);
|
||||
return {
|
||||
auto,
|
||||
mode: raw.mode ?? "final",
|
||||
provider:
|
||||
normalizeConfiguredSpeechProviderId(raw.provider) ??
|
||||
(providerSource === "config" ? (normalizeOptionalLowercaseString(raw.provider) ?? "") : ""),
|
||||
providerSource,
|
||||
persona,
|
||||
personas: collectTtsPersonas(raw),
|
||||
summaryModel: normalizeOptionalString(raw.summaryModel),
|
||||
modelOverrides: resolveModelOverridePolicy(raw.modelOverrides),
|
||||
providerConfigs: collectDirectProviderConfigEntries(raw),
|
||||
prefsPath: raw.prefsPath,
|
||||
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
|
||||
timeoutMs,
|
||||
timeoutMsSource,
|
||||
rawConfig: raw,
|
||||
sourceConfig: cfg,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTtsPrefsPath(config: ResolvedTtsConfig): string {
|
||||
return resolveTtsPrefsPathValue(config.prefsPath);
|
||||
}
|
||||
|
||||
function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined {
|
||||
const auto = normalizeTtsAutoMode(prefs.tts?.auto);
|
||||
if (auto) {
|
||||
return auto;
|
||||
}
|
||||
if (typeof prefs.tts?.enabled === "boolean") {
|
||||
return prefs.tts.enabled ? "always" : "off";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveTtsAutoMode(params: {
|
||||
config: ResolvedTtsConfig;
|
||||
prefsPath: string;
|
||||
sessionAuto?: string;
|
||||
}): TtsAutoMode {
|
||||
const sessionAuto = normalizeTtsAutoMode(params.sessionAuto);
|
||||
if (sessionAuto) {
|
||||
return sessionAuto;
|
||||
}
|
||||
const prefsAuto = resolveTtsAutoModeFromPrefs(readPrefs(params.prefsPath));
|
||||
if (prefsAuto) {
|
||||
return prefsAuto;
|
||||
}
|
||||
return params.config.auto;
|
||||
}
|
||||
|
||||
function resolveEffectiveTtsAutoState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionAuto?: string;
|
||||
agentId?: string;
|
||||
channelId?: string;
|
||||
accountId?: string;
|
||||
}): {
|
||||
autoMode: TtsAutoMode;
|
||||
prefsPath: string;
|
||||
} {
|
||||
const raw: TtsConfig = resolveEffectiveTtsConfig(params.cfg, {
|
||||
agentId: params.agentId,
|
||||
channelId: params.channelId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const prefsPath = resolveTtsPrefsPathValue(raw.prefsPath);
|
||||
const sessionAuto = normalizeTtsAutoMode(params.sessionAuto);
|
||||
if (sessionAuto) {
|
||||
return { autoMode: sessionAuto, prefsPath };
|
||||
}
|
||||
const prefsAuto = resolveTtsAutoModeFromPrefs(readPrefs(prefsPath));
|
||||
if (prefsAuto) {
|
||||
return { autoMode: prefsAuto, prefsPath };
|
||||
}
|
||||
return {
|
||||
autoMode: resolveConfiguredTtsAutoMode(raw),
|
||||
prefsPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTtsSystemPromptHint(
|
||||
cfgInput: OpenClawConfig,
|
||||
agentId?: string,
|
||||
): string | undefined {
|
||||
let cfg = cfgInput;
|
||||
cfg = resolveTtsRuntimeConfig(cfg);
|
||||
const { autoMode, prefsPath } = resolveEffectiveTtsAutoState({ cfg, agentId });
|
||||
if (autoMode === "off") {
|
||||
return undefined;
|
||||
}
|
||||
const configForTest = resolveTtsConfig(cfg, agentId);
|
||||
const persona = getTtsPersona(configForTest, prefsPath);
|
||||
const maxLength = getTtsMaxLength(prefsPath);
|
||||
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
|
||||
const autoHint =
|
||||
autoMode === "inbound"
|
||||
? "Only use TTS when the user's last message includes audio/voice."
|
||||
: autoMode === "tagged"
|
||||
? "Only use TTS when you include [[tts:key=value]] directives or a [[tts:text]]...[[/tts:text]] block."
|
||||
: undefined;
|
||||
return [
|
||||
"Voice (TTS) is enabled.",
|
||||
autoHint,
|
||||
persona
|
||||
? `Active TTS persona: ${persona.label ?? persona.id}${persona.description ? ` - ${persona.description}` : ""}.`
|
||||
: undefined,
|
||||
`Keep spoken text ≤${maxLength} chars to avoid auto-summary (summary ${summarize}).`,
|
||||
"If workspace context (especially MEMORY.md) tells you not to use [[tts:...]] or to use a local/non-tagged voice workflow, follow that workspace instruction instead.",
|
||||
"Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function readPrefs(prefsPath: string): TtsUserPrefs {
|
||||
try {
|
||||
if (!existsSync(prefsPath)) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(readFileSync(prefsPath, "utf8")) as TtsUserPrefs;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function atomicWriteFileSync(filePath: string, content: string): void {
|
||||
privateFileStoreSync(path.dirname(filePath)).writeText(path.basename(filePath), content);
|
||||
}
|
||||
|
||||
function updatePrefs(prefsPath: string, update: (prefs: TtsUserPrefs) => void): void {
|
||||
const prefs = readPrefs(prefsPath);
|
||||
update(prefs);
|
||||
atomicWriteFileSync(prefsPath, JSON.stringify(prefs, null, 2));
|
||||
}
|
||||
|
||||
export function isTtsEnabled(
|
||||
config: ResolvedTtsConfig,
|
||||
prefsPath: string,
|
||||
sessionAuto?: string,
|
||||
): boolean {
|
||||
return resolveTtsAutoMode({ config, prefsPath, sessionAuto }) !== "off";
|
||||
}
|
||||
|
||||
export function setTtsAutoMode(prefsPath: string, mode: TtsAutoMode): void {
|
||||
updatePrefs(prefsPath, (prefs) => {
|
||||
const next = { ...prefs.tts };
|
||||
delete next.enabled;
|
||||
next.auto = mode;
|
||||
prefs.tts = next;
|
||||
});
|
||||
}
|
||||
|
||||
export function setTtsEnabled(prefsPath: string, enabled: boolean): void {
|
||||
setTtsAutoMode(prefsPath, enabled ? "always" : "off");
|
||||
}
|
||||
|
||||
export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider {
|
||||
const prefs = readPrefs(prefsPath);
|
||||
const prefsProvider =
|
||||
@@ -811,44 +466,6 @@ export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): Tt
|
||||
return config.provider;
|
||||
}
|
||||
|
||||
function resolveTtsPersonaFromPrefs(
|
||||
config: ResolvedTtsConfig,
|
||||
prefs: TtsUserPrefs,
|
||||
): ResolvedTtsPersona | undefined {
|
||||
if (prefs.tts && hasOwnProperty(prefs.tts, "persona")) {
|
||||
const prefsPersona = normalizeTtsPersonaId(prefs.tts.persona);
|
||||
return prefsPersona ? config.personas[prefsPersona] : undefined;
|
||||
}
|
||||
const configPersona = normalizeTtsPersonaId(config.persona);
|
||||
return configPersona ? config.personas[configPersona] : undefined;
|
||||
}
|
||||
|
||||
export function getTtsPersona(
|
||||
config: ResolvedTtsConfig,
|
||||
prefsPath: string,
|
||||
): ResolvedTtsPersona | undefined {
|
||||
return resolveTtsPersonaFromPrefs(config, readPrefs(prefsPath));
|
||||
}
|
||||
|
||||
export function listTtsPersonas(config: ResolvedTtsConfig): ResolvedTtsPersona[] {
|
||||
return Object.values(config.personas).toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export function setTtsPersona(prefsPath: string, persona: string | null | undefined): void {
|
||||
updatePrefs(prefsPath, (prefs) => {
|
||||
const next = { ...prefs.tts };
|
||||
const normalized = normalizeTtsPersonaId(persona);
|
||||
next.persona = normalized ?? null;
|
||||
prefs.tts = next;
|
||||
});
|
||||
}
|
||||
|
||||
export function setTtsProvider(prefsPath: string, provider: TtsProvider): void {
|
||||
updatePrefs(prefsPath, (prefs) => {
|
||||
prefs.tts = { ...prefs.tts, provider: canonicalizeSpeechProviderId(provider) ?? provider };
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveExplicitTtsOverrides(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prefsPath?: string;
|
||||
@@ -917,28 +534,6 @@ export function resolveExplicitTtsOverrides(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function getTtsMaxLength(prefsPath: string): number {
|
||||
const prefs = readPrefs(prefsPath);
|
||||
return prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH;
|
||||
}
|
||||
|
||||
export function setTtsMaxLength(prefsPath: string, maxLength: number): void {
|
||||
updatePrefs(prefsPath, (prefs) => {
|
||||
prefs.tts = { ...prefs.tts, maxLength };
|
||||
});
|
||||
}
|
||||
|
||||
export function isSummarizationEnabled(prefsPath: string): boolean {
|
||||
const prefs = readPrefs(prefsPath);
|
||||
return prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE;
|
||||
}
|
||||
|
||||
export function setSummarizationEnabled(prefsPath: string, enabled: boolean): void {
|
||||
updatePrefs(prefsPath, (prefs) => {
|
||||
prefs.tts = { ...prefs.tts, summarize: enabled };
|
||||
});
|
||||
}
|
||||
|
||||
export function getLastTtsAttempt(): TtsStatusEntry | undefined {
|
||||
return lastTtsAttempt;
|
||||
}
|
||||
@@ -1955,7 +1550,7 @@ export async function maybeApplyTtsToPayload(params: {
|
||||
return params.payload;
|
||||
}
|
||||
const cfg = resolveTtsRuntimeConfig(params.cfg);
|
||||
const { autoMode, prefsPath } = resolveEffectiveTtsAutoState({
|
||||
const { autoMode, config, prefsPath } = resolveTtsSettingsSnapshot({
|
||||
cfg,
|
||||
sessionAuto: params.ttsAuto,
|
||||
agentId: params.agentId,
|
||||
@@ -1965,11 +1560,6 @@ export async function maybeApplyTtsToPayload(params: {
|
||||
if (autoMode === "off") {
|
||||
return params.payload;
|
||||
}
|
||||
const config = resolveTtsConfig(cfg, {
|
||||
agentId: params.agentId,
|
||||
channelId: params.channel,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const activeProvider = getTtsProvider(config, prefsPath);
|
||||
|
||||
const reply = resolveSendableOutboundReplyParts(params.payload);
|
||||
|
||||
@@ -147,6 +147,9 @@ export const pluginSdkDocMetadata = {
|
||||
"speech-core": {
|
||||
category: "provider",
|
||||
},
|
||||
"speech-settings": {
|
||||
category: "provider",
|
||||
},
|
||||
"realtime-voice": {
|
||||
category: "provider",
|
||||
},
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"agent-runtime",
|
||||
"simple-completion-runtime",
|
||||
"speech-core",
|
||||
"speech-settings",
|
||||
"tts-runtime",
|
||||
"plugin-runtime",
|
||||
"skills-runtime",
|
||||
|
||||
@@ -210,7 +210,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
publicEntrypoints: readPluginSdkSurfaceBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS",
|
||||
// Registry sweep: 77 packages, zero fetch failures; retired dead channel-ingress facade.
|
||||
328,
|
||||
// +1: speech-settings keeps agent prompt imports off the synthesis/runtime graph.
|
||||
329,
|
||||
env,
|
||||
),
|
||||
// ScopeTree adds six channel-policy exports, mirrored by compat, including three functions.
|
||||
@@ -242,9 +243,10 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
// +1: shared speech-provider API key resolver.
|
||||
// +32: shared channel setup, config-schema, policy, and status helpers.
|
||||
// +2: shared channel replay-guard factory and claim handle.
|
||||
// +6: lightweight speech settings types, normalizers, and config resolver.
|
||||
// Harvest: retired AudioConfig type -1.
|
||||
// +6: shared progress receipt tracker + compositor snapshot across channel barrels.
|
||||
7991,
|
||||
7997,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
@@ -266,7 +268,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
// +24: shared channel setup, config-schema, policy, and status helpers.
|
||||
// +1: shared channel replay-guard factory.
|
||||
// +3: receipt tracker/snapshot callables across channel barrels.
|
||||
4468,
|
||||
// +3: lightweight speech settings normalizers and config resolver.
|
||||
4471,
|
||||
env,
|
||||
),
|
||||
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
@@ -85,7 +85,7 @@ vi.mock("../skills/research/autocapture.js", () => ({
|
||||
runSkillResearchAutoCapture: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../tts/tts.js", () => ({
|
||||
vi.mock("../tts/tts-settings.js", () => ({
|
||||
buildTtsSystemPromptHint: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
||||
import { buildCliAgentSystemPrompt } from "./helpers.js";
|
||||
|
||||
vi.mock("../../tts/tts.js", () => ({
|
||||
vi.mock("../../tts/tts-settings.js", () => ({
|
||||
buildTtsSystemPromptHint: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ vi.mock("../../plugin-sdk/anthropic-cli.js", () => ({
|
||||
isClaudeCliProvider: (providerId: string) => providerId === "claude-cli",
|
||||
}));
|
||||
|
||||
vi.mock("../../tts/tts.js", () => ({
|
||||
vi.mock("../../tts/tts-settings.js", () => ({
|
||||
buildTtsSystemPromptHint: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
|
||||
@@ -444,7 +444,7 @@ vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({
|
||||
hoisted.ensureGlobalUndiciStreamTimeoutsMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../tts/tts.js", () => ({
|
||||
vi.mock("../../../tts/tts-settings.js", () => ({
|
||||
buildTtsSystemPromptHint: () => undefined,
|
||||
}));
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import type { AgentSession } from "../sessions/index.js";
|
||||
import { applySystemPromptToSession, buildEmbeddedSystemPrompt } from "./system-prompt.js";
|
||||
|
||||
vi.mock("../../tts/tts.js", () => ({
|
||||
vi.mock("../../tts/tts-settings.js", () => ({
|
||||
buildTtsSystemPromptHint: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { buildConfiguredAgentSystemPrompt } from "./system-prompt-config.js";
|
||||
|
||||
vi.mock("../tts/tts.js", () => ({
|
||||
vi.mock("../tts/tts-settings.js", () => ({
|
||||
buildTtsSystemPromptHint: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* prompt so callers do not duplicate owner, TTS, alias, memory, or FS policy.
|
||||
*/
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { buildTtsSystemPromptHint } from "../tts/tts.js";
|
||||
import { buildTtsSystemPromptHint } from "../tts/tts-settings.js";
|
||||
import { resolveAgentConfig } from "./agent-scope.js";
|
||||
import { buildModelAliasLines } from "./model-alias-lines.js";
|
||||
import { resolveOwnerDisplaySetting } from "./owner-display.js";
|
||||
|
||||
@@ -70,7 +70,7 @@ vi.mock("../../agents/agent-tools.js", () => ({
|
||||
createOpenClawCodingTools: createOpenClawCodingToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../tts/tts.js", () => ({
|
||||
vi.mock("../../tts/tts-settings.js", () => ({
|
||||
buildTtsSystemPromptHint: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Lightweight speech settings primitives for package-owned TTS configuration.
|
||||
// Keep provider registries and synthesis runtimes out of this entrypoint.
|
||||
|
||||
export type { ResolvedTtsConfig, ResolvedTtsModelOverrides } from "../tts/tts-types.js";
|
||||
export { normalizeSpeechProviderId } from "../tts/provider-registry-core.js";
|
||||
export { normalizeTtsAutoMode } from "../tts/tts-auto-mode.js";
|
||||
export { resolveEffectiveTtsConfig, type TtsConfigResolutionContext } from "../tts/tts-config.js";
|
||||
@@ -35,6 +35,33 @@ async function withStatusTempHome(run: (home: string) => Promise<void>): Promise
|
||||
}
|
||||
|
||||
describe("resolveStatusTtsSnapshot", () => {
|
||||
it("treats null prefs as empty settings", async () => {
|
||||
await withStatusTempHome(async (home) => {
|
||||
const prefsPath = path.join(home, ".openclaw", "settings", "tts.json");
|
||||
fs.mkdirSync(path.dirname(prefsPath), { recursive: true });
|
||||
fs.writeFileSync(prefsPath, "null");
|
||||
|
||||
expect(
|
||||
resolveStatusTtsSnapshot({
|
||||
cfg: {
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "edge",
|
||||
prefsPath,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
}),
|
||||
).toEqual({
|
||||
autoMode: "always",
|
||||
provider: "microsoft",
|
||||
maxLength: 1500,
|
||||
summarize: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("uses prefs overrides without loading speech providers", async () => {
|
||||
await withStatusTempHome(async (home) => {
|
||||
const prefsPath = path.join(home, ".openclaw", "settings", "tts.json");
|
||||
|
||||
+9
-113
@@ -1,34 +1,13 @@
|
||||
// TTS status config helpers resolve status output paths for speech generation.
|
||||
import path from "node:path";
|
||||
import { isRecord as isObjectRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import type { TtsAutoMode, TtsConfig, TtsProvider } from "../config/types.tts.js";
|
||||
import { tryReadJsonSync } from "../infra/json-files.js";
|
||||
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||
import { normalizeTtsAutoMode } from "./tts-auto-mode.js";
|
||||
import { resolveEffectiveTtsConfig, type TtsConfigResolutionContext } from "./tts-config.js";
|
||||
import { resolveTtsSettingsSnapshot } from "./tts-settings.js";
|
||||
|
||||
const DEFAULT_TTS_MAX_LENGTH = 1500;
|
||||
const DEFAULT_TTS_SUMMARIZE = true;
|
||||
const DEFAULT_OPENAI_TTS_BASE_URL = "https://api.openai.com/v1";
|
||||
const MAX_STATUS_DETAIL_LENGTH = 96;
|
||||
|
||||
type TtsUserPrefs = {
|
||||
tts?: {
|
||||
auto?: TtsAutoMode;
|
||||
enabled?: boolean;
|
||||
provider?: TtsProvider;
|
||||
persona?: string | null;
|
||||
maxLength?: number;
|
||||
summarize?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type TtsStatusSnapshot = {
|
||||
autoMode: TtsAutoMode;
|
||||
provider: TtsProvider;
|
||||
@@ -42,68 +21,6 @@ type TtsStatusSnapshot = {
|
||||
summarize: boolean;
|
||||
};
|
||||
|
||||
function resolveConfiguredTtsAutoMode(raw: TtsConfig): TtsAutoMode {
|
||||
return normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off");
|
||||
}
|
||||
|
||||
function normalizeConfiguredSpeechProviderId(
|
||||
providerId: string | undefined,
|
||||
): TtsProvider | undefined {
|
||||
const normalized = normalizeOptionalLowercaseString(providerId);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized === "edge" ? "microsoft" : normalized;
|
||||
}
|
||||
|
||||
function normalizeTtsPersonaId(personaId: string | null | undefined): string | undefined {
|
||||
return normalizeOptionalLowercaseString(personaId ?? undefined);
|
||||
}
|
||||
|
||||
function resolvePersonaPreferredProvider(
|
||||
raw: TtsConfig,
|
||||
personaId: string | undefined,
|
||||
): TtsProvider | undefined {
|
||||
if (!personaId || !raw.personas) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [id, persona] of Object.entries(raw.personas)) {
|
||||
if (normalizeTtsPersonaId(id) !== personaId) {
|
||||
continue;
|
||||
}
|
||||
const provider = normalizeConfiguredSpeechProviderId(persona.provider) ?? persona.provider;
|
||||
return normalizeOptionalString(provider);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveTtsPrefsPathValue(prefsPath: string | undefined): string {
|
||||
const configuredPath = normalizeOptionalString(prefsPath);
|
||||
if (configuredPath) {
|
||||
return resolveUserPath(configuredPath);
|
||||
}
|
||||
const envPath = normalizeOptionalString(process.env.OPENCLAW_TTS_PREFS);
|
||||
if (envPath) {
|
||||
return resolveUserPath(envPath);
|
||||
}
|
||||
return path.join(resolveConfigDir(process.env), "settings", "tts.json");
|
||||
}
|
||||
|
||||
function readPrefs(prefsPath: string): TtsUserPrefs {
|
||||
return tryReadJsonSync<TtsUserPrefs>(prefsPath) ?? {};
|
||||
}
|
||||
|
||||
function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined {
|
||||
const auto = normalizeTtsAutoMode(prefs.tts?.auto);
|
||||
if (auto) {
|
||||
return auto;
|
||||
}
|
||||
if (typeof prefs.tts?.enabled === "boolean") {
|
||||
return prefs.tts.enabled ? "always" : "off";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeStatusDetail(
|
||||
value: unknown,
|
||||
maxLength = MAX_STATUS_DETAIL_LENGTH,
|
||||
@@ -225,39 +142,18 @@ export function resolveStatusTtsSnapshot(params: {
|
||||
channelId?: string;
|
||||
accountId?: string;
|
||||
}): TtsStatusSnapshot | null {
|
||||
const context: TtsConfigResolutionContext = {
|
||||
agentId: params.agentId,
|
||||
channelId: params.channelId,
|
||||
accountId: params.accountId,
|
||||
};
|
||||
const raw: TtsConfig = resolveEffectiveTtsConfig(params.cfg, context);
|
||||
const prefsPath = resolveTtsPrefsPathValue(raw.prefsPath);
|
||||
const prefs = readPrefs(prefsPath);
|
||||
const autoMode =
|
||||
normalizeTtsAutoMode(params.sessionAuto) ??
|
||||
resolveTtsAutoModeFromPrefs(prefs) ??
|
||||
resolveConfiguredTtsAutoMode(raw);
|
||||
|
||||
if (autoMode === "off") {
|
||||
const settings = resolveTtsSettingsSnapshot(params);
|
||||
if (settings.autoMode === "off") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persona =
|
||||
prefs.tts && Object.hasOwn(prefs.tts, "persona")
|
||||
? normalizeTtsPersonaId(prefs.tts.persona)
|
||||
: normalizeTtsPersonaId(raw.persona);
|
||||
const provider =
|
||||
normalizeConfiguredSpeechProviderId(prefs.tts?.provider) ??
|
||||
resolvePersonaPreferredProvider(raw, persona) ??
|
||||
normalizeConfiguredSpeechProviderId(raw.provider) ??
|
||||
"auto";
|
||||
const provider = settings.preferredProvider ?? "auto";
|
||||
|
||||
return {
|
||||
autoMode,
|
||||
autoMode: settings.autoMode,
|
||||
provider,
|
||||
...resolveStatusProviderDetails(raw, provider),
|
||||
...(persona ? { persona } : {}),
|
||||
maxLength: prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH,
|
||||
summarize: prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE,
|
||||
...resolveStatusProviderDetails(settings.config.rawConfig ?? {}, provider),
|
||||
...(settings.personaId ? { persona: settings.personaId } : {}),
|
||||
maxLength: settings.maxLength,
|
||||
summarize: settings.summarize,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// TTS core tests cover provider selection, synthesis, and error handling.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AssistantMessage, Model, Usage } from "../llm/types.js";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js";
|
||||
@@ -33,6 +34,16 @@ const usage: Usage = {
|
||||
};
|
||||
|
||||
describe("TTS core", () => {
|
||||
it("keeps summarization-only LLM modules lazy", () => {
|
||||
const source = readFileSync(new URL("./tts-core.ts", import.meta.url), "utf8");
|
||||
|
||||
expect(source).toContain('import("../llm/stream.js")');
|
||||
expect(source).toContain('import("../agents/simple-completion-runtime.js")');
|
||||
expect(source).not.toContain('from "../llm/stream.js"');
|
||||
expect(source).not.toContain('from "../agents/simple-completion-runtime.js"');
|
||||
expect(source).not.toContain('from "../agents/model-auth.js"');
|
||||
});
|
||||
|
||||
it("resolves the first non-blank speech provider API key", () => {
|
||||
expect(resolveSpeechProviderApiKey(undefined, " \t", " provider-key ", "fallback")).toBe(
|
||||
"provider-key",
|
||||
|
||||
+22
-16
@@ -1,15 +1,12 @@
|
||||
// TTS core coordinates text preparation, provider selection, and speech output.
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { requireApiKey } from "../agents/model-auth.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
resolveDefaultModelForAgent,
|
||||
resolveModelRefFromString,
|
||||
type ModelRef,
|
||||
} from "../agents/model-selection.js";
|
||||
import { prepareSimpleCompletionModel } from "../agents/simple-completion-runtime.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { completeSimple } from "../llm/stream.js";
|
||||
import type { TextContent } from "../llm/types.js";
|
||||
import { resolveTimerTimeoutMs } from "../shared/number-coercion.js";
|
||||
import type { ResolvedTtsConfig } from "./tts-types.js";
|
||||
@@ -23,17 +20,25 @@ export {
|
||||
} from "./tts-provider-helpers.js";
|
||||
|
||||
type SummarizeTextDeps = {
|
||||
completeSimple: typeof completeSimple;
|
||||
prepareSimpleCompletionModel: typeof prepareSimpleCompletionModel;
|
||||
requireApiKey: typeof requireApiKey;
|
||||
completeSimple: typeof import("../llm/stream.js").completeSimple;
|
||||
prepareSimpleCompletionModel: typeof import("../agents/simple-completion-runtime.js").prepareSimpleCompletionModel;
|
||||
requireApiKey: typeof import("../agents/model-auth.js").requireApiKey;
|
||||
};
|
||||
|
||||
function resolveDefaultSummarizeTextDeps(): SummarizeTextDeps {
|
||||
return {
|
||||
completeSimple,
|
||||
prepareSimpleCompletionModel,
|
||||
let defaultSummarizeTextDepsPromise: Promise<SummarizeTextDeps> | undefined;
|
||||
|
||||
function loadDefaultSummarizeTextDeps(): Promise<SummarizeTextDeps> {
|
||||
// Speech provider imports should not initialize the LLM stack. Load it only
|
||||
// when synthesis actually needs summarization, then reuse the module bindings.
|
||||
return (defaultSummarizeTextDepsPromise ??= Promise.all([
|
||||
import("../llm/stream.js"),
|
||||
import("../agents/simple-completion-runtime.js"),
|
||||
import("../agents/model-auth.js"),
|
||||
]).then(([stream, completionRuntime, { requireApiKey }]) => ({
|
||||
completeSimple: stream.completeSimple,
|
||||
prepareSimpleCompletionModel: completionRuntime.prepareSimpleCompletionModel,
|
||||
requireApiKey,
|
||||
};
|
||||
})));
|
||||
}
|
||||
|
||||
type SummarizeResult = {
|
||||
@@ -83,7 +88,7 @@ export async function summarizeText(
|
||||
config: ResolvedTtsConfig;
|
||||
timeoutMs: number;
|
||||
},
|
||||
deps: SummarizeTextDeps = resolveDefaultSummarizeTextDeps(),
|
||||
deps?: SummarizeTextDeps,
|
||||
): Promise<SummarizeResult> {
|
||||
const { text, targetLength, cfg, config, timeoutMs } = params;
|
||||
if (targetLength < 100 || targetLength > 10_000) {
|
||||
@@ -91,10 +96,11 @@ export async function summarizeText(
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const resolvedDeps = deps ?? (await loadDefaultSummarizeTextDeps());
|
||||
const { ref } = resolveSummaryModelRef(cfg, config);
|
||||
// Dynamic model discovery precedes the request timeout, matching the established
|
||||
// summarization contract. The timeout below bounds only the completion request.
|
||||
const prepared = await deps.prepareSimpleCompletionModel({
|
||||
const prepared = await resolvedDeps.prepareSimpleCompletionModel({
|
||||
cfg,
|
||||
provider: ref.provider,
|
||||
modelId: ref.model,
|
||||
@@ -104,7 +110,7 @@ export async function summarizeText(
|
||||
throw new Error(prepared.error);
|
||||
}
|
||||
const completionModel = prepared.model;
|
||||
const apiKey = deps.requireApiKey(prepared.auth, ref.provider);
|
||||
const providerKey = resolvedDeps.requireApiKey(prepared.auth, ref.provider);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
@@ -114,7 +120,7 @@ export async function summarizeText(
|
||||
try {
|
||||
// Keep summarization on the simple-completion path so provider auth,
|
||||
// aliases, and timeout behavior match other lightweight model calls.
|
||||
const res = await deps.completeSimple(
|
||||
const res = await resolvedDeps.completeSimple(
|
||||
completionModel,
|
||||
{
|
||||
messages: [
|
||||
@@ -130,7 +136,7 @@ export async function summarizeText(
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey,
|
||||
apiKey: providerKey,
|
||||
maxTokens: Math.ceil(targetLength / 2),
|
||||
temperature: 0.3,
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Lightweight core facade for TTS settings used by agent and status hot paths.
|
||||
export {
|
||||
buildTtsSystemPromptHint,
|
||||
resolveTtsSettingsSnapshot,
|
||||
} from "../../packages/speech-core/src/tts-settings.js";
|
||||
@@ -16,4 +16,18 @@ describe("tts runtime facade", () => {
|
||||
expect(runtimeFacadeSource).toContain('from "../../packages/speech-core/runtime-api.js";');
|
||||
expect(runtimeFacadeSource).not.toContain('dirName: "speech-core"');
|
||||
});
|
||||
|
||||
it("keeps agent prompt TTS settings off the synthesis runtime chain", () => {
|
||||
const agentConfigSource = readSource("../agents/system-prompt-config.ts");
|
||||
const settingsFacadeSource = readSource("./tts-settings.ts");
|
||||
const packageSettingsSource = readSource("../../packages/speech-core/src/tts-settings.ts");
|
||||
|
||||
expect(agentConfigSource).toContain('from "../tts/tts-settings.js";');
|
||||
expect(settingsFacadeSource).toContain(
|
||||
'from "../../packages/speech-core/src/tts-settings.js";',
|
||||
);
|
||||
expect(settingsFacadeSource).not.toContain("tts-runtime");
|
||||
expect(packageSettingsSource).toContain('from "openclaw/plugin-sdk/speech-settings";');
|
||||
expect(packageSettingsSource).not.toContain("plugin-sdk/media-runtime");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Implementation stays in plugin-sdk/tts-runtime so provider surfaces share one contract.
|
||||
*/
|
||||
export {
|
||||
buildTtsSystemPromptHint,
|
||||
getLastTtsAttempt,
|
||||
getResolvedSpeechProviderConfig,
|
||||
getTtsMaxLength,
|
||||
|
||||
Reference in New Issue
Block a user