feat: talk mode key distribution and tts polling

This commit is contained in:
Peter Steinberger
2025-12-30 01:57:45 +01:00
parent 02db68aa67
commit e119a82334
17 changed files with 303 additions and 24 deletions

View File

@@ -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");
});
});
});

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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>;

View File

@@ -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)) {