feat: talk mode key distribution and tts polling
This commit is contained in:
@@ -174,3 +174,50 @@ describe("config identity defaults", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("talk api key fallback", () => {
|
||||
let previousEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousEnv = process.env.ELEVENLABS_API_KEY;
|
||||
delete process.env.ELEVENLABS_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ELEVENLABS_API_KEY = previousEnv;
|
||||
});
|
||||
|
||||
it("injects talk.apiKey from profile when config is missing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await fs.writeFile(
|
||||
path.join(home, ".profile"),
|
||||
"export ELEVENLABS_API_KEY=profile-key\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { readConfigFileSnapshot } = await import("./config.js");
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.config?.talk?.apiKey).toBe("profile-key");
|
||||
expect(snap.exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers ELEVENLABS_API_KEY env over profile", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await fs.writeFile(
|
||||
path.join(home, ".profile"),
|
||||
"export ELEVENLABS_API_KEY=profile-key\n",
|
||||
"utf-8",
|
||||
);
|
||||
process.env.ELEVENLABS_API_KEY = "env-key";
|
||||
|
||||
vi.resetModules();
|
||||
const { readConfigFileSnapshot } = await import("./config.js");
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.config?.talk?.apiKey).toBe("env-key");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,6 +226,8 @@ export type TalkConfig = {
|
||||
modelId?: string;
|
||||
/** Default ElevenLabs output format (e.g. mp3_44100_128). */
|
||||
outputFormat?: string;
|
||||
/** ElevenLabs API key (optional; falls back to ELEVENLABS_API_KEY). */
|
||||
apiKey?: string;
|
||||
/** Stop speaking when user starts talking (default: true). */
|
||||
interruptOnSpeech?: boolean;
|
||||
};
|
||||
@@ -802,6 +804,7 @@ const ClawdisSchema = z.object({
|
||||
voiceId: z.string().optional(),
|
||||
modelId: z.string().optional(),
|
||||
outputFormat: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
interruptOnSpeech: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
@@ -964,17 +967,59 @@ export function parseConfigJson5(
|
||||
}
|
||||
}
|
||||
|
||||
function readTalkApiKeyFromProfile(): string | null {
|
||||
const home = os.homedir();
|
||||
const candidates = [".profile", ".zprofile", ".zshrc", ".bashrc"].map(
|
||||
(name) => path.join(home, name),
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
try {
|
||||
const text = fs.readFileSync(candidate, "utf-8");
|
||||
const match = text.match(
|
||||
/(?:^|\n)\s*(?:export\s+)?ELEVENLABS_API_KEY\s*=\s*["']?([^\n"']+)["']?/,
|
||||
);
|
||||
const value = match?.[1]?.trim();
|
||||
if (value) return value;
|
||||
} catch {
|
||||
// Ignore profile read errors.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveTalkApiKey(): string | null {
|
||||
const envValue = (process.env.ELEVENLABS_API_KEY ?? "").trim();
|
||||
if (envValue) return envValue;
|
||||
return readTalkApiKeyFromProfile();
|
||||
}
|
||||
|
||||
function applyTalkApiKey(config: ClawdisConfig): ClawdisConfig {
|
||||
const resolved = resolveTalkApiKey();
|
||||
if (!resolved) return config;
|
||||
const existing = config.talk?.apiKey?.trim();
|
||||
if (existing) return config;
|
||||
return {
|
||||
...config,
|
||||
talk: {
|
||||
...config.talk,
|
||||
apiKey: resolved,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
const configPath = CONFIG_PATH_CLAWDIS;
|
||||
const exists = fs.existsSync(configPath);
|
||||
if (!exists) {
|
||||
const config = applyTalkApiKey({});
|
||||
return {
|
||||
path: configPath,
|
||||
exists: false,
|
||||
raw: null,
|
||||
parsed: {},
|
||||
valid: true,
|
||||
config: {},
|
||||
config,
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
@@ -1015,7 +1060,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
raw,
|
||||
parsed: parsedRes.parsed,
|
||||
valid: true,
|
||||
config: validated.config,
|
||||
config: applyTalkApiKey(validated.config),
|
||||
issues: [],
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -95,6 +95,8 @@ import {
|
||||
SnapshotSchema,
|
||||
type StateVersion,
|
||||
StateVersionSchema,
|
||||
type TalkModeParams,
|
||||
TalkModeParamsSchema,
|
||||
type TickEvent,
|
||||
TickEventSchema,
|
||||
type WakeParams,
|
||||
@@ -169,6 +171,8 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
|
||||
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
|
||||
ConfigSetParamsSchema,
|
||||
);
|
||||
export const validateTalkModeParams =
|
||||
ajv.compile<TalkModeParams>(TalkModeParamsSchema);
|
||||
export const validateProvidersStatusParams = ajv.compile<ProvidersStatusParams>(
|
||||
ProvidersStatusParamsSchema,
|
||||
);
|
||||
@@ -297,6 +301,7 @@ export type {
|
||||
NodePairApproveParams,
|
||||
ConfigGetParams,
|
||||
ConfigSetParams,
|
||||
TalkModeParams,
|
||||
ProvidersStatusParams,
|
||||
WebLoginStartParams,
|
||||
WebLoginWaitParams,
|
||||
|
||||
@@ -339,6 +339,14 @@ export const ConfigSetParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TalkModeParamsSchema = Type.Object(
|
||||
{
|
||||
enabled: Type.Boolean(),
|
||||
phase: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ProvidersStatusParamsSchema = Type.Object(
|
||||
{
|
||||
probe: Type.Optional(Type.Boolean()),
|
||||
@@ -668,6 +676,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
SessionsCompactParams: SessionsCompactParamsSchema,
|
||||
ConfigGetParams: ConfigGetParamsSchema,
|
||||
ConfigSetParams: ConfigSetParamsSchema,
|
||||
TalkModeParams: TalkModeParamsSchema,
|
||||
ProvidersStatusParams: ProvidersStatusParamsSchema,
|
||||
WebLoginStartParams: WebLoginStartParamsSchema,
|
||||
WebLoginWaitParams: WebLoginWaitParamsSchema,
|
||||
@@ -724,6 +733,7 @@ export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
||||
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
|
||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
||||
export type TalkModeParams = Static<typeof TalkModeParamsSchema>;
|
||||
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
|
||||
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
|
||||
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
|
||||
|
||||
@@ -393,6 +393,7 @@ import {
|
||||
validateSkillsInstallParams,
|
||||
validateSkillsStatusParams,
|
||||
validateSkillsUpdateParams,
|
||||
validateTalkModeParams,
|
||||
validateWakeParams,
|
||||
validateWebLoginStartParams,
|
||||
validateWebLoginWaitParams,
|
||||
@@ -469,6 +470,7 @@ const METHODS = [
|
||||
"status",
|
||||
"config.get",
|
||||
"config.set",
|
||||
"talk.mode",
|
||||
"models.list",
|
||||
"skills.status",
|
||||
"skills.install",
|
||||
@@ -518,6 +520,7 @@ const EVENTS = [
|
||||
"chat",
|
||||
"presence",
|
||||
"tick",
|
||||
"talk.mode",
|
||||
"shutdown",
|
||||
"health",
|
||||
"heartbeat",
|
||||
@@ -2379,6 +2382,25 @@ export async function startGatewayServer(
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "talk.mode": {
|
||||
const params = parseParams();
|
||||
if (!validateTalkModeParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const payload = {
|
||||
enabled: (params as { enabled: boolean }).enabled,
|
||||
phase: (params as { phase?: string }).phase ?? null,
|
||||
ts: Date.now(),
|
||||
};
|
||||
broadcast("talk.mode", payload, { dropIfSlow: true });
|
||||
return { ok: true, payloadJSON: JSON.stringify(payload) };
|
||||
}
|
||||
case "models.list": {
|
||||
const params = parseParams();
|
||||
if (!validateModelsListParams(params)) {
|
||||
@@ -4615,6 +4637,28 @@ export async function startGatewayServer(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "talk.mode": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateTalkModeParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const payload = {
|
||||
enabled: (params as { enabled: boolean }).enabled,
|
||||
phase: (params as { phase?: string }).phase ?? null,
|
||||
ts: Date.now(),
|
||||
};
|
||||
broadcast("talk.mode", payload, { dropIfSlow: true });
|
||||
respond(true, payload, undefined);
|
||||
break;
|
||||
}
|
||||
case "skills.status": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateSkillsStatusParams(params)) {
|
||||
|
||||
Reference in New Issue
Block a user