Add wakeword command routing and the reply-only agent client
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Veskslop agent client — the daemon side of the local LLM harness.
|
||||
*
|
||||
* Respond-only for now: the daemon posts a task and the harness returns the
|
||||
* reply text. The daemon sends that text through the normal paced path, so the
|
||||
* harness never talks to Discord and no tools are exposed to the model.
|
||||
*/
|
||||
|
||||
export interface AgentTask {
|
||||
id: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
content: string;
|
||||
context: Array<{ author: string; content: string; timestamp: string }>;
|
||||
}
|
||||
|
||||
export interface AgentClientOptions {
|
||||
url: string;
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export class AgentClient {
|
||||
private readonly base: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(private readonly options: AgentClientOptions) {
|
||||
this.base = options.url.replace(/\/+$/, "");
|
||||
this.timeoutMs = options.timeoutMs ?? 120_000;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return this.base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the harness for a reply. Returns the reply text, or undefined when
|
||||
* the harness answers with an empty reply.
|
||||
*/
|
||||
async respond(task: AgentTask): Promise<string | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.base}/task`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(this.options.token ? { authorization: `Bearer ${this.options.token}` } : {})
|
||||
},
|
||||
// mode: respond tells the harness to answer without tool calls.
|
||||
body: JSON.stringify({ ...task, mode: "respond" }),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(`agent returned ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`);
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => ({}))) as { reply?: unknown };
|
||||
const reply = typeof body.reply === "string" ? body.reply.trim() : "";
|
||||
return reply || undefined;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the client from the environment. The harness stays off unless
|
||||
* VESKSLOP_AGENT_ENABLED=1 is set.
|
||||
*/
|
||||
export function agentFromEnv(): AgentClient | undefined {
|
||||
if (process.env.VESKSLOP_AGENT_ENABLED !== "1") return undefined;
|
||||
|
||||
const url = process.env.VESKSLOP_AGENT_URL ?? "http://127.0.0.1:8790";
|
||||
const token = process.env.VESKSLOP_AGENT_TOKEN;
|
||||
const timeoutMs = Number(process.env.VESKSLOP_AGENT_TIMEOUT_MS ?? 120_000);
|
||||
|
||||
return new AgentClient({
|
||||
url,
|
||||
...(token ? { token } : {}),
|
||||
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120_000
|
||||
});
|
||||
}
|
||||
@@ -77,11 +77,32 @@ export interface ParsedCommand {
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export function parseCommand(text: string): ParsedCommand | undefined {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return undefined;
|
||||
export const DEFAULT_WAKEWORD = "!s";
|
||||
|
||||
const [name, ...args] = trimmed.split(/\s+/);
|
||||
/** Keep the wakeword short and free of whitespace. Bad input falls back. */
|
||||
export function normalizeWakeword(value: unknown): string {
|
||||
if (typeof value !== "string") return DEFAULT_WAKEWORD;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.length > 8 || /\s/.test(trimmed)) return DEFAULT_WAKEWORD;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a command. The message must start with the wakeword followed by a
|
||||
* space, so `!space` and `hey!s cmd` do not match. The match ignores letter
|
||||
* case; the returned name is lowercase.
|
||||
*/
|
||||
export function parseCommand(text: string, wakeword: string = DEFAULT_WAKEWORD): ParsedCommand | undefined {
|
||||
const trimmed = text.trim();
|
||||
const prefix = `${normalizeWakeword(wakeword)} `;
|
||||
if (trimmed.length <= prefix.length) return undefined;
|
||||
if (!trimmed.toLowerCase().startsWith(prefix.toLowerCase())) return undefined;
|
||||
|
||||
const rest = trimmed.slice(prefix.length).trim();
|
||||
if (!rest) return undefined;
|
||||
|
||||
const [name, ...args] = rest.split(/\s+/);
|
||||
return { name: name.toLowerCase(), args };
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { DEFAULT_VAD } from "./vad.js";
|
||||
import { CoreClient, coreSocketPath } from "./core-client.js";
|
||||
import { CoreProcess, staleBundleWarning } from "./core-process.js";
|
||||
import { dedupe, getQueryCount, paced, pacedSend, pacedVoice, sleep } from "./pacing.js";
|
||||
import { agentFromEnv } from "./agent-client.js";
|
||||
import { MediaService } from "./media.js";
|
||||
import { PreferencesStore } from "./preferences.js";
|
||||
import { Puppet } from "./puppet.js";
|
||||
@@ -52,7 +53,10 @@ async function connectLoop(): Promise<void> {
|
||||
const client = await CoreClient.connect();
|
||||
|
||||
core = client;
|
||||
puppet = new Puppet(client, audio, media);
|
||||
puppet = new Puppet(client, audio, media, agentFromEnv());
|
||||
|
||||
// The web preferences own the wakeword; the puppet's own state holds a copy.
|
||||
puppet.updateConfig({ wakeword: preferences.get().commands.wakeword });
|
||||
|
||||
client.onEvent(event => {
|
||||
// Speaking hints add to the reconciled set so the poller can
|
||||
@@ -472,6 +476,7 @@ function buildServer(): McpServer {
|
||||
contextChars: z.number().int().min(200).max(20000).optional(),
|
||||
fetchCooldownSeconds: z.number().int().min(0).max(300).optional(),
|
||||
continuationSeconds: z.number().int().min(0).max(300).optional(),
|
||||
wakeword: z.string().min(1).max(8).optional(),
|
||||
pollSeconds: z.number().int().min(10).max(3600).optional(),
|
||||
voiceLeaveDelaySeconds: z.number().int().min(0).max(600).optional(),
|
||||
responsePolicy: z
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
import { DEFAULT_WAKEWORD, normalizeWakeword } from "./communication.js";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
export interface TtsPreferences {
|
||||
@@ -128,6 +130,12 @@ export interface MediaTunables {
|
||||
};
|
||||
}
|
||||
|
||||
/** Chat command settings. */
|
||||
export interface CommandPreferences {
|
||||
/** A message must start with this string plus a space to be a command. */
|
||||
wakeword: string;
|
||||
}
|
||||
|
||||
/** Discord's microphone processing gates, plus a TTS bypass. */
|
||||
export interface VoicePreferences {
|
||||
noiseSuppression: boolean;
|
||||
@@ -151,6 +159,7 @@ export interface MediaPreferences {
|
||||
export interface WebPreferences {
|
||||
/** null means every guild is visible. An empty array hides all. */
|
||||
visibleGuilds: string[] | null;
|
||||
commands: CommandPreferences;
|
||||
tts: TtsPreferences;
|
||||
audio: AudioPreferences;
|
||||
voice: VoicePreferences;
|
||||
@@ -160,6 +169,9 @@ export interface WebPreferences {
|
||||
|
||||
const DEFAULTS: WebPreferences = {
|
||||
visibleGuilds: null,
|
||||
commands: {
|
||||
wakeword: DEFAULT_WAKEWORD
|
||||
},
|
||||
tts: {
|
||||
provider: null,
|
||||
language: null,
|
||||
@@ -393,6 +405,12 @@ export class PreferencesStore {
|
||||
: null;
|
||||
}
|
||||
|
||||
if (patch.commands !== undefined) {
|
||||
this.preferences.commands = {
|
||||
wakeword: normalizeWakeword(patch.commands?.wakeword)
|
||||
};
|
||||
}
|
||||
|
||||
if (patch.tts !== undefined) {
|
||||
this.preferences.tts = {
|
||||
provider: normalizeProvider(patch.tts?.provider),
|
||||
@@ -475,6 +493,9 @@ export class PreferencesStore {
|
||||
visibleGuilds: Array.isArray(raw?.visibleGuilds)
|
||||
? [...new Set<string>(raw.visibleGuilds.map((id: unknown) => String(id)))]
|
||||
: null,
|
||||
commands: {
|
||||
wakeword: normalizeWakeword(raw?.commands?.wakeword)
|
||||
},
|
||||
tts: {
|
||||
provider: normalizeProvider(provider),
|
||||
language: raw?.tts?.language ? String(raw.tts.language).slice(0, 16) : null,
|
||||
|
||||
+88
-22
@@ -13,8 +13,10 @@ import { dirname, join } from "node:path";
|
||||
import { ActionLog } from "./action-log.js";
|
||||
import {
|
||||
assembleContext,
|
||||
DEFAULT_WAKEWORD,
|
||||
hasTier,
|
||||
MessageLog,
|
||||
normalizeWakeword,
|
||||
parseCommand,
|
||||
parsePoll,
|
||||
parseTier,
|
||||
@@ -50,6 +52,7 @@ export interface PuppetConfig {
|
||||
contextChars: number;
|
||||
fetchCooldownSeconds: number;
|
||||
continuationSeconds: number;
|
||||
wakeword: string;
|
||||
pollSeconds: number;
|
||||
voiceLeaveDelaySeconds: number;
|
||||
responsePolicy: ResponsePolicy;
|
||||
@@ -60,6 +63,8 @@ export interface PuppetConfigPatch {
|
||||
contextChars?: number;
|
||||
fetchCooldownSeconds?: number;
|
||||
continuationSeconds?: number;
|
||||
/** Command prefix: a message must start with it plus a space. */
|
||||
wakeword?: string;
|
||||
pollSeconds?: number;
|
||||
voiceLeaveDelaySeconds?: number;
|
||||
responsePolicy?: {
|
||||
@@ -74,6 +79,7 @@ const DEFAULT_CONFIG: PuppetConfig = {
|
||||
contextChars: 4000,
|
||||
fetchCooldownSeconds: 5,
|
||||
continuationSeconds: 10,
|
||||
wakeword: DEFAULT_WAKEWORD,
|
||||
pollSeconds: 120,
|
||||
voiceLeaveDelaySeconds: clamp(Number(process.env.VESKSLOP_VOICE_LEAVE_DELAY ?? 30), 0, 600),
|
||||
responsePolicy: {
|
||||
@@ -104,6 +110,22 @@ export interface PuppetTransport {
|
||||
voiceLeave(): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** A trusted free-form message, for the reply-only agent. */
|
||||
export interface PuppetAgentRequest {
|
||||
id: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
content: string;
|
||||
context: Array<{ author: string; content: string; timestamp: string }>;
|
||||
}
|
||||
|
||||
/** The reply-only LLM harness. It never receives commands and cannot act. */
|
||||
export interface PuppetAgent {
|
||||
respond(request: PuppetAgentRequest): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
/** The audio actions a chat command can trigger. */
|
||||
export interface PuppetAudio {
|
||||
speak(text: string, options?: { provider?: "local" | "api"; voice?: string }): Promise<unknown>;
|
||||
@@ -140,23 +162,27 @@ interface PollState {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const WHITELIST_HELP = [
|
||||
"whitelist — manage who can use the puppet.",
|
||||
"Usage:",
|
||||
" whitelist Show this help",
|
||||
" whitelist list Show the owner and the trusted users",
|
||||
" whitelist add @user [tier] Add a user. Tier: user, trusted, admin (default user)",
|
||||
" whitelist remove @user Remove a user"
|
||||
].join("\n");
|
||||
function whitelistHelp(wakeword: string): string {
|
||||
return [
|
||||
`${wakeword} whitelist — manage who can use the puppet.`,
|
||||
"Usage:",
|
||||
` ${wakeword} whitelist Show this help`,
|
||||
` ${wakeword} whitelist list Show the owner and the trusted users`,
|
||||
` ${wakeword} whitelist add @user [tier] Add a user. Tier: user, trusted, admin (default user)`,
|
||||
` ${wakeword} whitelist remove @user Remove a user`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const SCRAPE_HELP = [
|
||||
"scrape media — archive this channel's media.",
|
||||
"Usage:",
|
||||
" scrape media Index links (default)",
|
||||
" scrape media download Also download attachment bytes (admin)",
|
||||
" scrape media status Show this channel's latest scrape",
|
||||
" scrape media stop Stop it (admin)"
|
||||
].join("\n");
|
||||
function scrapeHelp(wakeword: string): string {
|
||||
return [
|
||||
`${wakeword} scrape media — archive this channel's media.`,
|
||||
"Usage:",
|
||||
` ${wakeword} scrape media Index links (default)`,
|
||||
` ${wakeword} scrape media download Also download attachment bytes (admin)`,
|
||||
` ${wakeword} scrape media status Show this channel's latest scrape`,
|
||||
` ${wakeword} scrape media stop Stop it (admin)`
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
@@ -195,7 +221,8 @@ export class Puppet {
|
||||
constructor(
|
||||
private readonly core: PuppetTransport,
|
||||
private readonly audio?: PuppetAudio,
|
||||
private readonly media?: PuppetMedia
|
||||
private readonly media?: PuppetMedia,
|
||||
private readonly agent?: PuppetAgent
|
||||
) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
|
||||
@@ -224,6 +251,7 @@ export class Puppet {
|
||||
0,
|
||||
300
|
||||
),
|
||||
wakeword: normalizeWakeword(state.config.wakeword ?? DEFAULT_CONFIG.wakeword),
|
||||
pollSeconds: clamp(state.config.pollSeconds ?? DEFAULT_CONFIG.pollSeconds, 10, 3600),
|
||||
voiceLeaveDelaySeconds: clamp(
|
||||
state.config.voiceLeaveDelaySeconds ?? DEFAULT_CONFIG.voiceLeaveDelaySeconds,
|
||||
@@ -277,6 +305,7 @@ export class Puppet {
|
||||
300
|
||||
),
|
||||
continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300),
|
||||
wakeword: normalizeWakeword(patch.wakeword ?? this.config.wakeword),
|
||||
pollSeconds: clamp(patch.pollSeconds ?? this.config.pollSeconds, 10, 3600),
|
||||
voiceLeaveDelaySeconds: clamp(
|
||||
patch.voiceLeaveDelaySeconds ?? this.config.voiceLeaveDelaySeconds,
|
||||
@@ -499,7 +528,7 @@ export class Puppet {
|
||||
}
|
||||
|
||||
const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
|
||||
const command = parseCommand(text);
|
||||
const command = parseCommand(text, this.config.wakeword);
|
||||
|
||||
const entry: PuppetMessage = {
|
||||
id: event.id,
|
||||
@@ -565,7 +594,44 @@ export class Puppet {
|
||||
} else if (command?.name === "scrape") {
|
||||
await this.handleScrapeCommand(command, author.id, channelId, guildId, tier);
|
||||
} else if (POLL_ENABLED && command?.name === "poll") {
|
||||
await this.handlePollCommand(author.id, channelId, text);
|
||||
const body = text.trim().slice(this.config.wakeword.length).trim();
|
||||
await this.handlePollCommand(author.id, channelId, body);
|
||||
} else {
|
||||
// No command: the message belongs to the reply-only agent.
|
||||
void this.handleFreeform(entry, text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a trusted free-form message to the agent and reply with its answer.
|
||||
* Runs detached: the agent can take minutes and must not block other events.
|
||||
*/
|
||||
private async handleFreeform(entry: PuppetMessage, text: string) {
|
||||
if (!this.agent) return;
|
||||
|
||||
try {
|
||||
const reply = await this.agent.respond({
|
||||
id: entry.id,
|
||||
authorId: entry.authorId,
|
||||
authorName: entry.authorName,
|
||||
channelId: entry.channelId,
|
||||
...(entry.guildId ? { guildId: entry.guildId } : {}),
|
||||
content: text,
|
||||
context: (entry.context ?? []).map(message => ({
|
||||
author: message.authorName,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp
|
||||
}))
|
||||
});
|
||||
|
||||
if (!reply) {
|
||||
this.actionLog.append({ type: "agent", messageId: entry.id, result: "empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.respond(entry.authorId, entry.channelId, "chat", reply);
|
||||
} catch (error) {
|
||||
this.actionLog.append({ type: "agent", messageId: entry.id, result: "error", error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,7 +652,7 @@ export class Puppet {
|
||||
|
||||
// No arguments or "help" shows the help. No permission needed for help.
|
||||
if (!sub || sub === "help") {
|
||||
return this.respond(authorId, channelId, "whitelist", WHITELIST_HELP);
|
||||
return this.respond(authorId, channelId, "whitelist", whitelistHelp(this.config.wakeword));
|
||||
}
|
||||
|
||||
if (!hasTier(tier, "admin")) {
|
||||
@@ -655,7 +721,7 @@ export class Puppet {
|
||||
}
|
||||
|
||||
default:
|
||||
return this.respond(authorId, channelId, "whitelist", WHITELIST_HELP);
|
||||
return this.respond(authorId, channelId, "whitelist", whitelistHelp(this.config.wakeword));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,7 +781,7 @@ export class Puppet {
|
||||
const knownSub = sub === undefined || sub === "download" || sub === "status" || sub === "stop";
|
||||
|
||||
if (kind !== "media" || sub === "help" || !knownSub) {
|
||||
return this.respond(authorId, channelId, "scrape", SCRAPE_HELP);
|
||||
return this.respond(authorId, channelId, "scrape", scrapeHelp(this.config.wakeword));
|
||||
}
|
||||
|
||||
if (!this.media) {
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
formatContextLine,
|
||||
hasTier,
|
||||
isTrusted,
|
||||
DEFAULT_WAKEWORD,
|
||||
normalizeWakeword,
|
||||
parseCommand,
|
||||
parsePoll,
|
||||
parseTier,
|
||||
@@ -65,11 +67,28 @@ function entry(id: number, trusted: boolean): PuppetMessage {
|
||||
|
||||
// commands
|
||||
{
|
||||
assert.deepEqual(parseCommand(" WHITELIST add <@123> "), { name: "whitelist", args: ["add", "<@123>"] });
|
||||
assert.deepEqual(parseCommand("whitelist list"), { name: "whitelist", args: ["list"] });
|
||||
assert.deepEqual(parseCommand(" !S WHITELIST add <@123> "), { name: "whitelist", args: ["add", "<@123>"] });
|
||||
assert.deepEqual(parseCommand("!s whitelist list"), { name: "whitelist", args: ["list"] });
|
||||
assert.deepEqual(parseCommand("?q speak stop", "?q"), { name: "speak", args: ["stop"] });
|
||||
|
||||
// The wakeword must sit at the start and a space must follow it.
|
||||
assert.equal(parseCommand("whitelist list"), undefined);
|
||||
assert.equal(parseCommand("!space"), undefined);
|
||||
assert.equal(parseCommand("fuckface!s whitelist list"), undefined);
|
||||
assert.equal(parseCommand("!s"), undefined);
|
||||
assert.equal(parseCommand(" "), undefined);
|
||||
}
|
||||
|
||||
// wakeword values
|
||||
{
|
||||
assert.equal(normalizeWakeword(" !x "), "!x");
|
||||
assert.equal(normalizeWakeword("?"), "?");
|
||||
assert.equal(normalizeWakeword(""), DEFAULT_WAKEWORD);
|
||||
assert.equal(normalizeWakeword("has space"), DEFAULT_WAKEWORD);
|
||||
assert.equal(normalizeWakeword("waytoolong"), DEFAULT_WAKEWORD);
|
||||
assert.equal(normalizeWakeword(undefined), DEFAULT_WAKEWORD);
|
||||
}
|
||||
|
||||
// trust
|
||||
{
|
||||
assert.equal(isTrusted("owner", "owner", []), true);
|
||||
|
||||
@@ -13,7 +13,7 @@ import net from "node:net";
|
||||
import { coreSocketPath } from "./core-client.js";
|
||||
|
||||
const authorId = process.env.TEST_TRIGGER_AUTHOR ?? "177117296140091393";
|
||||
const content = process.env.TEST_TRIGGER_CONTENT ?? "whitelist list";
|
||||
const content = process.env.TEST_TRIGGER_CONTENT ?? "!s whitelist list";
|
||||
const guildId = process.env.TEST_TRIGGER_GUILD;
|
||||
const isGuild = !!guildId;
|
||||
const mention = isGuild && process.env.TEST_TRIGGER_MENTION !== "0";
|
||||
@@ -44,7 +44,7 @@ const updateEvent = {
|
||||
id: process.env.TEST_TRIGGER_MESSAGE_ID ?? "400000000000000004",
|
||||
channelId: process.env.TEST_TRIGGER_CHANNEL ?? "200000000000000001",
|
||||
guildId: guildId ?? "100000000000000001",
|
||||
content: content === "whitelist list" ? "edited content" : content,
|
||||
content: content === "!s whitelist list" ? "edited content" : content,
|
||||
editedAt: new Date().toISOString(),
|
||||
author: { id: authorId, username: "zzib", bot: false }
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
*/
|
||||
|
||||
import express, { type Request, type Response } from "express";
|
||||
|
||||
import { normalizeWakeword } from "./communication.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { Readable } from "node:stream";
|
||||
import http from "node:http";
|
||||
@@ -486,6 +488,7 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
wrap(async (req, res) => {
|
||||
const {
|
||||
visibleGuilds,
|
||||
commands: commandPrefs,
|
||||
tts,
|
||||
audio,
|
||||
voice: voicePrefs,
|
||||
@@ -556,6 +559,10 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
deps.audio.setDuckEnabled(patch.audio.duck);
|
||||
}
|
||||
|
||||
if (commandPrefs !== undefined) {
|
||||
patch.commands = { wakeword: normalizeWakeword(commandPrefs?.wakeword) };
|
||||
}
|
||||
|
||||
if (voicePrefs !== undefined) {
|
||||
patch.voice = {
|
||||
noiseSuppression: voicePrefs?.noiseSuppression === true,
|
||||
@@ -568,6 +575,7 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
|
||||
const updated = deps.preferences.update(patch);
|
||||
if (mediaPrefs !== undefined) deps.media.applyTunables();
|
||||
if (commandPrefs !== undefined) deps.getPuppet()?.updateConfig({ wakeword: updated.commands.wakeword });
|
||||
if (voicePrefs !== undefined) {
|
||||
const core = deps.getCore();
|
||||
if (core && deps.isCoreConnected()) {
|
||||
|
||||
Reference in New Issue
Block a user