feat: add Azure Speech voice mode
CI / Verify and package (ubuntu-latest) (push) Canceled after 0s
CI / Verify and package (windows-latest) (push) Canceled after 0s

This commit is contained in:
snowspeeder
2026-08-04 07:28:48 -04:00
parent c09b67d15a
commit c3e0011378
19 changed files with 875 additions and 11 deletions
+159 -1
View File
@@ -33,7 +33,8 @@
"marked": "^18.0.6", "marked": "^18.0.6",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"typebox": "1.3.6", "typebox": "1.3.6",
"ws": "^8.21.0" "ws": "^8.21.0",
"microsoft-cognitiveservices-speech-sdk": "^1.51.0"
}, },
"bin": { "bin": {
"pi-web": "dist/cli.js", "pi-web": "dist/cli.js",
@@ -10085,6 +10086,163 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.28 || ^4" "zod": "^3.25.28 || ^4"
} }
},
"node_modules/@azure/abort-controller": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz",
"integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@azure/core-auth": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz",
"integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-util": "^1.13.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@azure/core-util": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz",
"integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@typespec/ts-http-runtime": "^0.3.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@types/webrtc": {
"version": "0.0.37",
"resolved": "https://registry.npmjs.org/@types/webrtc/-/webrtc-0.0.37.tgz",
"integrity": "sha512-JGAJC/ZZDhcrrmepU4sPLQLIOIAgs5oIK+Ieq90K8fdaNMhfdfqmYatJdgif1NDQtvrSlTOGJDUYHIDunuufOg==",
"license": "MIT"
},
"node_modules/@typespec/ts-http-runtime": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.8.tgz",
"integrity": "sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==",
"license": "MIT",
"dependencies": {
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/bent": {
"version": "7.3.12",
"resolved": "https://registry.npmjs.org/bent/-/bent-7.3.12.tgz",
"integrity": "sha512-T3yrKnVGB63zRuoco/7Ybl7BwwGZR0lceoVG5XmQyMIH9s19SV5m+a8qam4if0zQuAmOQTyPTPmsQBdAorGK3w==",
"license": "Apache-2.0",
"dependencies": {
"bytesish": "^0.4.1",
"caseless": "~0.12.0",
"is-stream": "^2.0.0"
}
},
"node_modules/bytesish": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/bytesish/-/bytesish-0.4.4.tgz",
"integrity": "sha512-i4uu6M4zuMUiyfZN4RU2+i9+peJh//pXhd9x1oSe1LBkZ3LEbCoygu8W0bXTukU1Jme2txKuotpCZRaC3FLxcQ==",
"license": "(Apache-2.0 AND MIT)"
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0"
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/microsoft-cognitiveservices-speech-sdk": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/microsoft-cognitiveservices-speech-sdk/-/microsoft-cognitiveservices-speech-sdk-1.51.0.tgz",
"integrity": "sha512-BLLovv5PegOr5Lp52h4CSgL2c/ViFAh9LoU49ILNPyb/Z0giGI7nPV3xNLcmTtHLT+kfYNRGUIA62vORLzP8TA==",
"license": "MIT",
"dependencies": {
"@azure/core-auth": "^1.9.0",
"@types/webrtc": "^0.0.37",
"agent-base": "^6.0.1",
"bent": "^7.3.12",
"https-proxy-agent": "^4.0.0",
"uuid": "^11.1.1",
"ws": "^8.21.0"
}
},
"node_modules/microsoft-cognitiveservices-speech-sdk/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/microsoft-cognitiveservices-speech-sdk/node_modules/https-proxy-agent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz",
"integrity": "sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg==",
"license": "MIT",
"dependencies": {
"agent-base": "5",
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/microsoft-cognitiveservices-speech-sdk/node_modules/https-proxy-agent/node_modules/agent-base": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-5.1.1.tgz",
"integrity": "sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
} }
} }
} }
+1
View File
@@ -77,6 +77,7 @@
"fastify": "^5.10.0", "fastify": "^5.10.0",
"lit": "^3.3.3", "lit": "^3.3.3",
"marked": "^18.0.6", "marked": "^18.0.6",
"microsoft-cognitiveservices-speech-sdk": "^1.51.0",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"typebox": "1.3.6", "typebox": "1.3.6",
"ws": "^8.21.0" "ws": "^8.21.0"
+2 -2
View File
@@ -1,5 +1,5 @@
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { activityApi, api, azureSpeechApi, configApi, filesApi, gitApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads"; export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AskUserCloseResponse, AskUserQuestion, AskUserSubmission, PendingAskUser, PendingExtensionDialog, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, ExtensionDialogCloseReason, ExtensionDialogCloseResponse, ExtensionDialogKind, ExtensionDialogOutcome, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AzureSpeechSettings, AzureSpeechSettingsUpdate, AzureSpeechToken, AskUserCloseResponse, AskUserQuestion, AskUserSubmission, PendingAskUser, PendingExtensionDialog, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, ExtensionDialogCloseReason, ExtensionDialogCloseResponse, ExtensionDialogKind, ExtensionDialogOutcome, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+9 -1
View File
@@ -1,9 +1,11 @@
import type { AskUserSubmission, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes"; import type { AskUserSubmission, AzureSpeechSettings, AzureSpeechSettingsUpdate, AzureSpeechToken, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { resolveAppUrl } from "../appUrl"; import { resolveAppUrl } from "../appUrl";
import { request } from "./http"; import { request } from "./http";
import { import {
arrayOf, arrayOf,
parseAborted, parseAborted,
parseAzureSpeechSettings,
parseAzureSpeechToken,
parseAskUserCloseResponse, parseAskUserCloseResponse,
parseAccepted, parseAccepted,
parseArchived, parseArchived,
@@ -137,6 +139,12 @@ export const configApi = {
saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }), saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configPath(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
}; };
export const azureSpeechApi = {
settings: (): Promise<AzureSpeechSettings> => request("api/azure-speech", parseAzureSpeechSettings),
saveSettings: (settings: AzureSpeechSettingsUpdate): Promise<AzureSpeechSettings> => request("api/azure-speech", parseAzureSpeechSettings, { method: "PUT", body: JSON.stringify(settings) }),
token: (): Promise<AzureSpeechToken> => request("api/azure-speech/token", parseAzureSpeechToken, { method: "POST" }),
};
export const pluginsApi = { export const pluginsApi = {
plugins: (machineId?: string) => request(pluginsPath(machineId), parsePiWebPluginsResponse), plugins: (machineId?: string) => request(pluginsPath(machineId), parsePiWebPluginsResponse),
}; };
+11 -1
View File
@@ -1,4 +1,4 @@
import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, ASK_USER_TEXT_MAX_LENGTH, EXTENSION_DIALOG_ID_MAX_LENGTH, EXTENSION_DIALOG_INPUT_MAX_LENGTH, EXTENSION_DIALOG_OPTION_LIMIT, EXTENSION_DIALOG_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AskUserCloseReason, type AskUserCloseResponse, type AskUserOutcome, type AskUserQuestion, type AskUserQuestionOption, type AskUserQuestionRecord, type PendingAskUser, type PendingExtensionDialog, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type ExtensionDialogAnswer, type ExtensionDialogCloseReason, type ExtensionDialogCloseResponse, type ExtensionDialogKind, type ExtensionDialogOutcome, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes"; import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, ASK_USER_TEXT_MAX_LENGTH, EXTENSION_DIALOG_ID_MAX_LENGTH, EXTENSION_DIALOG_INPUT_MAX_LENGTH, EXTENSION_DIALOG_OPTION_LIMIT, EXTENSION_DIALOG_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AzureSpeechSettings, type AzureSpeechToken, type AskUserCloseReason, type AskUserCloseResponse, type AskUserOutcome, type AskUserQuestion, type AskUserQuestionOption, type AskUserQuestionRecord, type PendingAskUser, type PendingExtensionDialog, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type ExtensionDialogAnswer, type ExtensionDialogCloseReason, type ExtensionDialogCloseResponse, type ExtensionDialogKind, type ExtensionDialogOutcome, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionActivity, SessionStartupProgressEvent, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes"; import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionActivity, SessionStartupProgressEvent, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile"; import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities"; import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
@@ -1207,6 +1207,16 @@ export function parseWorkspaceActivityResponse(value: unknown): WorkspaceActivit
return { workspaces: arrayOf(parseWorkspaceActivity)(record["workspaces"]), generatedAt: requireString(record, "generatedAt") }; return { workspaces: arrayOf(parseWorkspaceActivity)(record["workspaces"]), generatedAt: requireString(record, "generatedAt") };
} }
export function parseAzureSpeechSettings(value: unknown): AzureSpeechSettings {
const record = requireRecord(value);
return { region: requireString(record, "region"), voice: requireString(record, "voice"), hasApiKey: requireBoolean(record, "hasApiKey") };
}
export function parseAzureSpeechToken(value: unknown): AzureSpeechToken {
const record = requireRecord(value);
return { token: requireString(record, "token"), region: requireString(record, "region"), voice: requireString(record, "voice") };
}
export function parsePiWebConfigResponse(value: unknown): PiWebConfigResponse { export function parsePiWebConfigResponse(value: unknown): PiWebConfigResponse {
const record = requireRecord(value); const record = requireRecord(value);
return { return {
+164
View File
@@ -0,0 +1,164 @@
import * as SpeechSDK from "microsoft-cognitiveservices-speech-sdk";
import { azureSpeechApi } from "./api";
const VOICE_MODE_STORAGE_KEY = "pi-web.azure-speech.voice-mode";
export const VOICE_MODE_RESUME_EVENT = "pi-web.azure-speech.resume";
export function voiceModeEnabled(): boolean {
return window.localStorage.getItem(VOICE_MODE_STORAGE_KEY) === "true";
}
export function setVoiceModeEnabled(enabled: boolean): void {
window.localStorage.setItem(VOICE_MODE_STORAGE_KEY, String(enabled));
}
export function resumeVoiceMode(): void {
window.dispatchEvent(new Event(VOICE_MODE_RESUME_EVENT));
}
/** Call from the microphone tap handler so iOS permits later response playback. */
export function primeVoiceAudio(): void {
AzureSpeechClient.primeSpeaker();
}
export class AzureSpeechClient {
static speaker: HTMLAudioElement | undefined;
private recognizer: SpeechSDK.SpeechRecognizer | undefined;
private recognitionStopped: (() => void) | undefined;
private synthesizer: SpeechSDK.SpeechSynthesizer | undefined;
static primeSpeaker(): void {
const speaker = this.speaker ?? new Audio();
this.speaker = speaker;
// A muted, zero-length WAV played directly from the mic button's user
// gesture unlocks this element for later Azure response playback on iOS.
speaker.muted = true;
speaker.src = "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=";
void speaker.play().then(() => { speaker.pause(); }).catch(() => undefined);
}
get recording(): boolean {
return this.recognizer !== undefined;
}
async startRecognition(options: { onReady?: () => void; onInterim: (text: string) => void; onFinal: (text: string) => void; onStopped: () => void; }): Promise<void> {
if (this.recognizer !== undefined) return;
if (!window.isSecureContext || !hasMicrophoneAccess(navigator)) {
throw new Error("Voice input requires PI WEB to be opened over HTTPS. iPhone browsers do not expose the microphone to an http:// LAN address.");
}
const settings = await azureSpeechApi.token();
const speechConfig = SpeechSDK.SpeechConfig.fromAuthorizationToken(settings.token, settings.region);
speechConfig.speechRecognitionLanguage = navigator.language || "en-US";
// Azure's endpoint detector acts as voice-activity detection: when a
// speaker pauses for this long, it finalizes the utterance and we send it.
speechConfig.setProperty(SpeechSDK.PropertyId.SpeechServiceConnection_EndSilenceTimeoutMs, "1200");
const recognizer = new SpeechSDK.SpeechRecognizer(speechConfig, SpeechSDK.AudioConfig.fromDefaultMicrophoneInput());
this.recognizer = recognizer;
this.recognitionStopped = options.onStopped;
recognizer.sessionStarted = () => { options.onReady?.(); };
recognizer.recognizing = (_sender, event) => { options.onInterim(event.result.text); };
recognizer.canceled = () => { this.finishRecognition(options.onStopped); };
recognizer.recognizeOnceAsync(
(result) => {
if (result.reason === SpeechSDK.ResultReason.RecognizedSpeech && result.text.trim() !== "") options.onFinal(result.text);
this.finishRecognition(options.onStopped);
},
() => { this.finishRecognition(options.onStopped); },
);
}
stopRecognition(): Promise<void> {
if (this.recognizer !== undefined) this.finishRecognition();
return Promise.resolve();
}
async speak(text: string): Promise<void> {
const spoken = text.trim();
if (spoken === "") return;
this.stopSpeaking();
const settings = await azureSpeechApi.token();
const speechConfig = SpeechSDK.SpeechConfig.fromAuthorizationToken(settings.token, settings.region);
if (settings.voice !== "") speechConfig.speechSynthesisVoiceName = settings.voice;
// Safari's Media Source support is most reliable with an MP3 stream.
speechConfig.speechSynthesisOutputFormat = SpeechSDK.SpeechSynthesisOutputFormat.Audio24Khz48KBitRateMonoMp3;
// Receive the MP3 bytes from Azure, then play them through the audio
// element primed by the microphone tap. SpeakerAudioDestination relies on
// Media Source Extensions and is unreliable in mobile Safari.
const synthesizer = new SpeechSDK.SpeechSynthesizer(speechConfig, null);
this.synthesizer = synthesizer;
try {
const audioData = await synthesisAudio(synthesizer, spoken);
await playAudioData(audioData);
} finally {
if (this.synthesizer === synthesizer) this.synthesizer = undefined;
synthesizer.close();
}
}
stopSpeaking(): void {
const synthesizer = this.synthesizer;
this.synthesizer = undefined;
synthesizer?.close();
}
dispose(): void {
void this.stopRecognition();
this.stopSpeaking();
}
private finishRecognition(onStopped?: () => void): void {
const recognizer = this.recognizer;
const stopped = onStopped ?? this.recognitionStopped;
this.recognizer = undefined;
this.recognitionStopped = undefined;
recognizer?.close();
stopped?.();
}
}
function hasMicrophoneAccess(value: Navigator): boolean {
const mediaDevices: unknown = Reflect.get(value, "mediaDevices");
return typeof mediaDevices === "object" && mediaDevices !== null && typeof Reflect.get(mediaDevices, "getUserMedia") === "function";
}
function synthesisAudio(synthesizer: SpeechSDK.SpeechSynthesizer, text: string): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
synthesizer.speakTextAsync(
text,
(result) => {
if (result.reason !== SpeechSDK.ResultReason.SynthesizingAudioCompleted) {
reject(new Error(`Azure Speech synthesis failed: ${result.errorDetails || String(result.reason)}`));
return;
}
resolve(result.audioData);
},
(error) => { reject(new Error(error)); },
);
});
}
function playAudioData(audioData: ArrayBuffer): Promise<void> {
const speaker = AzureSpeechClient.speaker ?? new Audio();
AzureSpeechClient.speaker = speaker;
speaker.muted = false;
const url = URL.createObjectURL(new Blob([audioData], { type: "audio/mpeg" }));
speaker.src = url;
return new Promise((resolve, reject) => {
const releaseAudioSession = () => {
speaker.onended = null;
speaker.onerror = null;
speaker.pause();
speaker.removeAttribute("src");
speaker.load();
URL.revokeObjectURL(url);
};
speaker.onended = () => {
releaseAudioSession();
// iOS keeps the playback audio session briefly after `ended`; asking for
// the microphone immediately can leave the next recognition silent.
window.setTimeout(resolve, 600);
};
speaker.onerror = () => { releaseAudioSession(); reject(new Error("Unable to play Azure Speech audio.")); };
void speaker.play().catch((error: unknown) => { releaseAudioSession(); reject(error instanceof Error ? error : new Error(String(error))); });
});
}
+9
View File
@@ -76,6 +76,7 @@ import { shouldShowMachinesSection, type AppNavigationPanel, type NavigationFocu
import "./appShell/AppPanelEdgeControl"; import "./appShell/AppPanelEdgeControl";
import "./appShell/AppRefreshControl"; import "./appShell/AppRefreshControl";
import { appStyles } from "./shared"; import { appStyles } from "./shared";
import { AzureSpeechClient, resumeVoiceMode, voiceModeEnabled } from "../azureSpeechClient";
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000; const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
@@ -110,6 +111,7 @@ export class PiWebApp extends LitElement {
@query("#navigation-panel") private navigationPanelFrame?: HTMLElement; @query("#navigation-panel") private navigationPanelFrame?: HTMLElement;
@query("#workspace-panel") private workspacePanelFrame?: HTMLElement; @query("#workspace-panel") private workspacePanelFrame?: HTMLElement;
private readonly azureSpeech = new AzureSpeechClient();
private readonly sessionUnread = new SessionUnreadController({ private readonly sessionUnread = new SessionUnreadController({
onChange: (machineId) => { onChange: (machineId) => {
this.syncUnreadPresence(); this.syncUnreadPresence();
@@ -142,6 +144,12 @@ export class PiWebApp extends LitElement {
onSelectedSessionReady: ({ machineId, session }) => { onSelectedSessionReady: ({ machineId, session }) => {
void this.commitReadyChatAfterRender(machineId, session); void this.commitReadyChatAfterRender(machineId, session);
}, },
onFinalAssistantResponse: ({ text }) => {
if (!voiceModeEnabled()) return;
void this.azureSpeech.speak(text)
.catch((error: unknown) => { console.warn("Azure Speech synthesis failed", error); })
.finally(() => { resumeVoiceMode(); });
},
replacePromptEditorText: async ({ machineId, sessionId, text }) => { replacePromptEditorText: async ({ machineId, sessionId, text }) => {
await this.updateComplete; await this.updateComplete;
if (selectedMachineId(this.state) !== machineId || this.state.selectedSession?.id !== sessionId) return; if (selectedMachineId(this.state) !== machineId || this.state.selectedSession?.id !== sessionId) return;
@@ -397,6 +405,7 @@ export class PiWebApp extends LitElement {
this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange); this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange);
this.keyboard.reset(); this.keyboard.reset();
this.auth.dispose(); this.auth.dispose();
this.azureSpeech.dispose();
this.sessions.dispose(); this.sessions.dispose();
this.notifications.dispose(); this.notifications.dispose();
this.realtime.close(); this.realtime.close();
+100 -1
View File
@@ -6,6 +6,7 @@ import { defaultHighlightStyle, indentOnInput, indentUnit, syntaxHighlighting }
import { LitElement, html, type PropertyValues } from "lit"; import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type PromptAttachment, type SessionModel, type SessionStatus, type SlashCommand } from "../api"; import { api, type FileSuggestion, type PromptAttachment, type SessionModel, type SessionStatus, type SlashCommand } from "../api";
import { AzureSpeechClient, primeVoiceAudio, setVoiceModeEnabled, VOICE_MODE_RESUME_EVENT } from "../azureSpeechClient";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture"; import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes"; import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes";
@@ -15,7 +16,7 @@ import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior"; import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior";
import { promptEditorStyles, type CompletionItem } from "./shared"; import { promptEditorStyles, type CompletionItem } from "./shared";
import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge, renderVoiceIcon } from "./promptEditorIcons";
import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels";
import "./AutocompleteMenu"; import "./AutocompleteMenu";
@@ -55,6 +56,15 @@ export class PromptEditor extends LitElement {
@state() private attachments: PendingAttachment[] = []; @state() private attachments: PendingAttachment[] = [];
@state() private attachmentDelivery: PromptAttachmentDelivery = loadAttachmentDelivery(); @state() private attachmentDelivery: PromptAttachmentDelivery = loadAttachmentDelivery();
@state() private attachmentError: string | undefined = undefined; @state() private attachmentError: string | undefined = undefined;
@state() private voiceRecording = false;
@state() private voiceError: string | undefined = undefined;
private readonly azureSpeech = new AzureSpeechClient();
private voiceTranscript = "";
private voicePrefix = "";
private sendAfterVoiceStop = false;
private readonly resumeVoiceRecognition = () => {
if (this.voiceRecording && !this.azureSpeech.recording) void this.startVoiceTurn();
};
private attachmentSeq = 0; private attachmentSeq = 0;
private requestVersion = 0; private requestVersion = 0;
private editor: EditorView | undefined; private editor: EditorView | undefined;
@@ -89,6 +99,7 @@ export class PromptEditor extends LitElement {
override firstUpdated(): void { override firstUpdated(): void {
this.createEditor(); this.createEditor();
window.addEventListener(VOICE_MODE_RESUME_EVENT, this.resumeVoiceRecognition);
} }
protected override updated(changed: PropertyValues) { protected override updated(changed: PropertyValues) {
@@ -97,6 +108,8 @@ export class PromptEditor extends LitElement {
} }
override disconnectedCallback(): void { override disconnectedCallback(): void {
window.removeEventListener(VOICE_MODE_RESUME_EVENT, this.resumeVoiceRecognition);
this.azureSpeech.dispose();
this.editor?.destroy(); this.editor?.destroy();
this.editor = undefined; this.editor = undefined;
super.disconnectedCallback(); super.disconnectedCallback();
@@ -116,10 +129,12 @@ export class PromptEditor extends LitElement {
${shellMode ? html`<div class="mode-hint">Shell command${shellInputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null} ${shellMode ? html`<div class="mode-hint">Shell command${shellInputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null} ${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
${this.renderAttachments()} ${this.renderAttachments()}
${this.voiceError === undefined ? null : html`<div class="mode-hint voice-error" role="alert">${this.voiceError}</div>`}
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu> <autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
</div> </div>
<div class="actions"> <div class="actions">
${this.renderCompactStatus()} ${this.renderCompactStatus()}
<button class=${`icon-button voice-button${this.voiceRecording ? " voice-recording" : ""}`} ?disabled=${busy} title=${this.voiceRecording ? "Cancel voice input" : "Start voice message"} aria-label=${this.voiceRecording ? "Cancel voice input" : "Start voice message"} @click=${() => { void this.toggleVoice(); }}>${renderVoiceIcon(this.voiceRecording)}</button>
<button class="icon-button send-button" ?disabled=${busy} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} aria-label=${queuesInput ? "Queue message" : "Send message"} @click=${() => { this.send("followUp"); }}>${queuesInput ? renderQueueIcon() : renderSendIcon()}</button> <button class="icon-button send-button" ?disabled=${busy} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} aria-label=${queuesInput ? "Queue message" : "Send message"} @click=${() => { this.send("followUp"); }}>${queuesInput ? renderQueueIcon() : renderSendIcon()}</button>
${this.canSteer && !this.isCompacting ? html`<button class="icon-button steer-button" ?disabled=${busy} title="Steer the current response before the next model call" aria-label="Steer current response" @click=${() => { this.send("steer"); }}>${renderSteerIcon()}</button>` : null} ${this.canSteer && !this.isCompacting ? html`<button class="icon-button steer-button" ?disabled=${busy} title="Steer the current response before the next model call" aria-label="Steer current response" @click=${() => { this.send("steer"); }}>${renderSteerIcon()}</button>` : null}
<button class="icon-button stop-button" ?disabled=${this.disabled || !this.canStop} title=${this.canStop ? "Stop current work and clear queued messages" : "Nothing running"} aria-label="Stop current work" @click=${() => this.onStop?.()}>${renderStopIcon()}</button> <button class="icon-button stop-button" ?disabled=${this.disabled || !this.canStop} title=${this.canStop ? "Stop current work and clear queued messages" : "Nothing running"} aria-label="Stop current work" @click=${() => this.onStop?.()}>${renderStopIcon()}</button>
@@ -467,6 +482,60 @@ export class PromptEditor extends LitElement {
this.completions = []; this.completions = [];
} }
private async toggleVoice(): Promise<void> {
this.voiceError = undefined;
if (this.voiceRecording) {
this.voiceRecording = false;
this.sendAfterVoiceStop = false;
setVoiceModeEnabled(false);
await this.azureSpeech.stopRecognition();
return;
}
primeVoiceAudio();
this.voiceRecording = true;
setVoiceModeEnabled(true);
await this.startVoiceTurn();
}
private async startVoiceTurn(announce = true): Promise<void> {
if (!this.voiceRecording || this.azureSpeech.recording) return;
this.voicePrefix = this.draft.trim();
this.voiceTranscript = "";
this.sendAfterVoiceStop = true;
const playReadyChime = announce ? prepareVoiceReadyChime() : undefined;
try {
await this.azureSpeech.startRecognition({
...(playReadyChime === undefined ? {} : { onReady: playReadyChime }),
onInterim: (text) => { this.replaceText(this.voiceText(text)); },
onFinal: (text) => {
this.voiceTranscript = [this.voiceTranscript, text].filter((part) => part !== "").join(" ");
this.replaceText(this.voiceText());
},
onStopped: () => { this.stopVoiceTurn(); },
});
} catch (error) {
this.voiceError = errorMessage(error);
this.voiceRecording = false;
this.sendAfterVoiceStop = false;
setVoiceModeEnabled(false);
}
}
private stopVoiceTurn(): void {
const shouldSend = this.sendAfterVoiceStop;
this.sendAfterVoiceStop = false;
if (!this.voiceRecording) return;
if (shouldSend && this.voiceTranscript.trim() !== "") {
this.send("followUp");
return;
}
void this.startVoiceTurn(false);
}
private voiceText(interim = ""): string {
return [this.voicePrefix, this.voiceTranscript, interim].filter((part) => part.trim() !== "").join(" ");
}
private send(streamingBehavior?: "steer" | "followUp") { private send(streamingBehavior?: "steer" | "followUp") {
if (this.disabled || this.sending) return; if (this.disabled || this.sending) return;
const text = this.draft.trim(); const text = this.draft.trim();
@@ -584,6 +653,36 @@ const codeLikeInputAssistanceAttributes: Record<string, string> = {
dir: "auto", dir: "auto",
}; };
function prepareVoiceReadyChime(): () => void {
// Create and resume the context in the tap handler. That keeps iOS' user-
// gesture audio permission alive until Azure confirms the microphone session.
const context = new AudioContext();
void context.resume();
let played = false;
return () => {
if (played) return;
played = true;
const start = context.currentTime;
for (const [offset, frequency] of [[0, 523.25], [0.09, 783.99]] as const) {
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(frequency, start + offset);
gain.gain.setValueAtTime(0.0001, start + offset);
gain.gain.exponentialRampToValueAtTime(0.055, start + offset + 0.018);
gain.gain.exponentialRampToValueAtTime(0.0001, start + offset + 0.24);
oscillator.connect(gain).connect(context.destination);
oscillator.start(start + offset);
oscillator.stop(start + offset + 0.25);
}
window.setTimeout(() => { void context.close(); }, 500);
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function inputAssistanceContentAttributes(draftBeforeCursor: string): Record<string, string> { function inputAssistanceContentAttributes(draftBeforeCursor: string): Record<string, string> {
// CodeMirror is optimized for code and disables these by default, but the chat prompt is usually prose. // CodeMirror is optimized for code and disables these by default, but the chat prompt is usually prose.
return inputModeForDraft(draftBeforeCursor).kind === "normal" ? proseInputAssistanceAttributes : codeLikeInputAssistanceAttributes; return inputModeForDraft(draftBeforeCursor).kind === "normal" ? proseInputAssistanceAttributes : codeLikeInputAssistanceAttributes;
+9 -1
View File
@@ -4,6 +4,7 @@ import type { AppAction } from "../actions";
import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api"; import { configApi, piPackagesApi, pluginsApi, type Machine, type MachineRuntime, type PiPackageMutationResponse, type PiPackageScope, type PiPackagesResponse, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api";
import type { SettingsSection } from "../settingsRoute"; import type { SettingsSection } from "../settingsRoute";
import "./settings/SettingsGeneralPanel"; import "./settings/SettingsGeneralPanel";
import "./settings/SettingsAzureSpeechPanel";
import "./settings/SettingsSessiondPanel"; import "./settings/SettingsSessiondPanel";
import "./settings/SettingsPackagesPanel"; import "./settings/SettingsPackagesPanel";
import "./settings/SettingsPluginsPanel"; import "./settings/SettingsPluginsPanel";
@@ -118,6 +119,7 @@ export class SettingsDialog extends LitElement {
${this.renderNavButton("packages", "Pi packages", "Selected machine")} ${this.renderNavButton("packages", "Pi packages", "Selected machine")}
${this.renderNavButton("plugins", "PI WEB plugins", "Selected machine")} ${this.renderNavButton("plugins", "PI WEB plugins", "Selected machine")}
${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")} ${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")}
${this.renderNavButton("azure-speech", "Azure Speech", "Voice input + replies")}
</nav> </nav>
<main class="settings-content"> <main class="settings-content">
${this.renderActiveSection()} ${this.renderActiveSection()}
@@ -148,6 +150,9 @@ export class SettingsDialog extends LitElement {
></settings-sessiond-panel> ></settings-sessiond-panel>
`; `;
} }
if (this.section === "azure-speech") {
return html`<settings-azure-speech-panel></settings-azure-speech-panel>`;
}
if (this.section === "shortcuts") { if (this.section === "shortcuts") {
return html` return html`
<settings-shortcuts-panel <settings-shortcuts-panel
@@ -686,7 +691,8 @@ export type SettingsPanelTag =
| "settings-sessiond-panel" | "settings-sessiond-panel"
| "settings-packages-panel" | "settings-packages-panel"
| "settings-plugins-panel" | "settings-plugins-panel"
| "settings-shortcuts-panel"; | "settings-shortcuts-panel"
| "settings-azure-speech-panel";
/** /**
* The single custom-element panel the settings dialog renders for a section. * The single custom-element panel the settings dialog renders for a section.
@@ -706,6 +712,8 @@ export function activeSettingsPanelTag(section: SettingsSection): SettingsPanelT
return "settings-plugins-panel"; return "settings-plugins-panel";
case "shortcuts": case "shortcuts":
return "settings-shortcuts-panel"; return "settings-shortcuts-panel";
case "azure-speech":
return "settings-azure-speech-panel";
case "general": case "general":
return "settings-general-panel"; return "settings-general-panel";
} }
@@ -13,6 +13,17 @@ export function renderAttachIcon(): TemplateResult {
`; `;
} }
export function renderVoiceIcon(recording = false): TemplateResult {
return svg`
<svg class=${`prompt-action-icon${recording ? " prompt-action-icon-filled" : ""}`} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="9" y="3" width="6" height="11" rx="3"></rect>
<path d="M6 11a6 6 0 0 0 12 0"></path>
<path d="M12 17v4"></path>
<path d="M8 21h8"></path>
</svg>
`;
}
export function renderSendIcon(): TemplateResult { export function renderSendIcon(): TemplateResult {
return svg` return svg`
<svg class="prompt-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <svg class="prompt-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -0,0 +1,123 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { azureSpeechApi, type AzureSpeechSettings } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
@customElement("settings-azure-speech-panel")
export class SettingsAzureSpeechPanel extends LitElement {
@state() private settings: AzureSpeechSettings | undefined;
@state() private region = "eastus";
@state() private voice = "";
@state() private apiKey = "";
@state() private loading = true;
@state() private saving = false;
@state() private error = "";
@state() private saved = "";
override connectedCallback(): void {
super.connectedCallback();
void this.load();
}
override render(): TemplateResult {
return html`
<settings-panel-frame
heading="Azure Speech"
description="Real-time speech recognition and spoken final replies. Your key is stored only on this PI WEB host and is never returned to the browser."
actionLabel="Reload"
.actionDisabled=${this.loading || this.saving}
.notices=${this.notices()}
.onAction=${() => { void this.load(); }}
>
<section class="settings-card">
<div class="card-heading">
<h3>Azure AI Speech</h3>
<p>Use the region for your Speech resource and the exact Azure voice name you want for text-to-speech. Leave the voice empty to use Azure's default voice.</p>
</div>
${this.loading ? html`<div class="loading-card">Loading Azure Speech settings…</div>` : html`
<form class="config-form" @submit=${(event: Event) => { void this.save(event); }}>
<label class="field">
<span>Speech region</span>
<input .value=${this.region} required autocomplete="off" spellcheck="false" placeholder="eastus" @input=${(event: Event) => { this.region = inputValue(event); this.error = ""; }}>
<small>For example, <code>eastus</code>. This must match the Azure Speech resource.</small>
</label>
<label class="field">
<span>DragonHD voice name</span>
<input .value=${this.voice} autocomplete="off" spellcheck="false" placeholder="en-US-Andrew:DragonHDLatestNeural" @input=${(event: Event) => { this.voice = inputValue(event); this.error = ""; }}>
<small>Paste the exact voice identifier available to your resource.</small>
</label>
<label class="field">
<span>Speech API key</span>
<input type="password" .value=${this.apiKey} autocomplete="new-password" spellcheck="false" placeholder=${this.settings?.hasApiKey === true ? "Saved — enter a replacement or leave blank" : "Azure AI Speech key"} @input=${(event: Event) => { this.apiKey = inputValue(event); this.error = ""; }}>
<small>${this.settings?.hasApiKey === true ? "A key is already saved. Leaving this blank keeps it unchanged; entering a new value replaces it." : "The key is sent once over your PI WEB connection and stored in a separate owner-only file."}</small>
</label>
<footer class="form-actions"><button class="primary" ?disabled=${this.saving}>${this.saving ? "Saving…" : "Save Azure Speech settings"}</button></footer>
</form>
`}
</section>
</settings-panel-frame>
`;
}
private notices(): readonly SettingsNotice[] {
return [
...(this.error === "" ? [] : [{ type: "error" as const, content: this.error }]),
...(this.saved === "" ? [] : [{ type: "success" as const, content: this.saved }]),
];
}
private async load(): Promise<void> {
this.loading = true;
this.error = "";
try {
const settings = await azureSpeechApi.settings();
this.settings = settings;
this.region = settings.region;
this.voice = settings.voice;
this.apiKey = "";
} catch (error) {
this.error = errorMessage(error);
} finally {
this.loading = false;
}
}
private async save(event: Event): Promise<void> {
event.preventDefault();
this.saving = true;
this.error = "";
this.saved = "";
try {
this.settings = await azureSpeechApi.saveSettings({ region: this.region, voice: this.voice, ...(this.apiKey === "" ? {} : { apiKey: this.apiKey }) });
this.apiKey = "";
this.saved = "Azure Speech settings saved.";
} catch (error) {
this.error = errorMessage(error);
} finally {
this.saving = false;
}
}
static override styles = css`
:host { display: block; }
.settings-card, .loading-card { display: grid; gap: 14px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.card-heading, .config-form, .field { display: grid; gap: 7px; }
h3, p { margin: 0; } p, small { color: var(--pi-muted); line-height: 1.45; }
.field > span { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
input, button { font: inherit; } input { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px 10px; }
input:focus { outline: none; border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); }
code { font: 12px ui-monospace, monospace; }
.form-actions { display: flex; justify-content: flex-end; padding-top: 2px; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; } .primary { border-color: var(--pi-accent); background: var(--pi-selection-bg); color: var(--pi-text-bright); }
`;
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : "";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -4,6 +4,37 @@ import { SessionController } from "./sessionController";
import { defaultApi, EmitSocket, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport"; import { defaultApi, EmitSocket, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport";
describe("SessionController live events", () => { describe("SessionController live events", () => {
it("emits only final assistant text after agent.end", async () => {
const socket = new EmitSocket();
const replies: string[] = [];
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
messages: [{ role: "assistant", parts: [{ type: "thinking", text: "private" }, { type: "text", text: "Final answer" }] }],
};
const api: typeof defaultApi = {
...defaultApi,
messages: () => Promise.resolve(emptyPage),
status: () => Promise.resolve(status(oldSession.id)),
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket, onFinalAssistantResponse: ({ text }) => { replies.push(text); } },
);
await controller.selectSession(oldSession, { updateUrl: false });
state = { ...state, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "private" }, { type: "text", text: "Final answer" }] }] };
socket.emit({ type: "agent.end" });
expect(replies).toEqual(["Final answer"]);
});
it("coalesces rapid status updates into a single state write per frame", () => { it("coalesces rapid status updates into a single state write per frame", () => {
const setStateCalls: Partial<AppState>[] = []; const setStateCalls: Partial<AppState>[] = [];
let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] }; let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] };
@@ -52,6 +52,12 @@ export interface SelectedSessionReady {
session: SessionInfo; session: SessionInfo;
} }
export interface FinalAssistantResponse {
machineId: string;
sessionId: string;
text: string;
}
export interface SessionControllerDependencies { export interface SessionControllerDependencies {
api?: typeof defaultApi; api?: typeof defaultApi;
socket?: SessionEventSocket; socket?: SessionEventSocket;
@@ -59,6 +65,7 @@ export interface SessionControllerDependencies {
notifications?: SessionNotificationSessionBridge; notifications?: SessionNotificationSessionBridge;
replacePromptEditorText?: (replacement: PromptEditorTextReplacement) => void | Promise<void>; replacePromptEditorText?: (replacement: PromptEditorTextReplacement) => void | Promise<void>;
onSelectedSessionReady?: (selection: SelectedSessionReady) => void; onSelectedSessionReady?: (selection: SelectedSessionReady) => void;
onFinalAssistantResponse?: (response: FinalAssistantResponse) => void;
} }
interface BulkSessionMutationResult { interface BulkSessionMutationResult {
@@ -111,6 +118,7 @@ export class SessionController {
private readonly notifications: SessionNotificationSessionBridge | undefined; private readonly notifications: SessionNotificationSessionBridge | undefined;
private readonly replacePromptEditorText: SessionControllerDependencies["replacePromptEditorText"]; private readonly replacePromptEditorText: SessionControllerDependencies["replacePromptEditorText"];
private readonly onSelectedSessionReady: SessionControllerDependencies["onSelectedSessionReady"]; private readonly onSelectedSessionReady: SessionControllerDependencies["onSelectedSessionReady"];
private readonly onFinalAssistantResponse: SessionControllerDependencies["onFinalAssistantResponse"];
private selectionSeq = 0; private selectionSeq = 0;
private disposed = false; private disposed = false;
// Join-time stream watermark for the selected session. `seq` is the // Join-time stream watermark for the selected session. `seq` is the
@@ -142,6 +150,7 @@ export class SessionController {
this.notifications = deps.notifications; this.notifications = deps.notifications;
this.replacePromptEditorText = deps.replacePromptEditorText; this.replacePromptEditorText = deps.replacePromptEditorText;
this.onSelectedSessionReady = deps.onSelectedSessionReady; this.onSelectedSessionReady = deps.onSelectedSessionReady;
this.onFinalAssistantResponse = deps.onFinalAssistantResponse;
} }
applyGlobalEvent(event: GlobalSessionEvent): void { applyGlobalEvent(event: GlobalSessionEvent): void {
@@ -1489,6 +1498,7 @@ export class SessionController {
} }
this.flushPendingUpdates(); this.flushPendingUpdates();
if (event.type === "agent.end") this.emitFinalAssistantResponse();
// Ask frames are applied after the buffered status they were published with, // Ask frames are applied after the buffered status they were published with,
// so the card follows the daemon's own open/close order. // so the card follows the daemon's own open/close order.
if (event.type === "ask.opened") { if (event.type === "ask.opened") {
@@ -1680,6 +1690,14 @@ export class SessionController {
} }
} }
private emitFinalAssistantResponse(): void {
const session = this.getState().selectedSession;
if (session === undefined) return;
const text = finalAssistantReply(this.getState().messages);
if (text === "") return;
this.onFinalAssistantResponse?.({ machineId: selectedMachineId(this.getState()), sessionId: session.id, text });
}
private clearPendingUpdates(): void { private clearPendingUpdates(): void {
this.pendingTranscriptEvents = []; this.pendingTranscriptEvents = [];
this.pendingStatusBySession.clear(); this.pendingStatusBySession.clear();
@@ -1701,6 +1719,11 @@ export class SessionController {
} }
} }
function finalAssistantReply(messages: AppState["messages"]): string {
const message = [...messages].reverse().find((line) => line.role === "assistant");
return message?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim() ?? "";
}
function omitSessionActivity(activities: Record<string, SessionActivity>, sessionId: string): Record<string, SessionActivity> { function omitSessionActivity(activities: Record<string, SessionActivity>, sessionId: string): Record<string, SessionActivity> {
return omitKey(activities, sessionId); return omitKey(activities, sessionId);
} }
+2 -1
View File
@@ -1,4 +1,4 @@
export type SettingsSection = "general" | "sessiond" | "packages" | "plugins" | "shortcuts"; export type SettingsSection = "general" | "sessiond" | "packages" | "plugins" | "shortcuts" | "azure-speech";
export function readSettingsSection(): SettingsSection | undefined { export function readSettingsSection(): SettingsSection | undefined {
return parseSettingsSection(new URLSearchParams(window.location.search).get("settings")); return parseSettingsSection(new URLSearchParams(window.location.search).get("settings"));
@@ -21,5 +21,6 @@ export function parseSettingsSection(value: string | null): SettingsSection | un
if (value === "packages" || value === "pi-packages") return "packages"; if (value === "packages" || value === "pi-packages") return "packages";
if (value === "plugins") return "plugins"; if (value === "plugins") return "plugins";
if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts"; if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts";
if (value === "azure-speech" || value === "voice") return "azure-speech";
return undefined; return undefined;
} }
+11 -2
View File
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyServerOptions } from "fastify"; import Fastify, { type FastifyInstance, type FastifyReply, type FastifyServerOptions } from "fastify";
import type { ServerOptions as HttpsServerOptions } from "node:https";
import fastifyCompress from "@fastify/compress"; import fastifyCompress from "@fastify/compress";
import fastifyStatic from "@fastify/static"; import fastifyStatic from "@fastify/static";
import fastifyWebsocket from "@fastify/websocket"; import fastifyWebsocket from "@fastify/websocket";
@@ -20,6 +21,7 @@ import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js"; import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { registerAzureSpeechRoutes } from "./azureSpeechRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js"; import { PiWebPluginService } from "./piWebPluginService.js";
import { createActiveProfilePiPackageService, type PiPackageService } from "./piPackageService.js"; import { createActiveProfilePiPackageService, type PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js"; import { registerPiPackageRoutes } from "./piPackageRoutes.js";
@@ -51,6 +53,7 @@ export interface AppDependencies {
logger?: FastifyServerOptions["logger"]; logger?: FastifyServerOptions["logger"];
/** Maximum accepted HTTP request body size in bytes. */ /** Maximum accepted HTTP request body size in bytes. */
bodyLimit?: number; bodyLimit?: number;
https?: HttpsServerOptions;
} }
interface LocalProjectRouteOptions { interface LocalProjectRouteOptions {
@@ -151,8 +154,12 @@ async function withProfileDependency<T>(reply: FastifyReply, operation: () => Pr
} }
} }
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> { export async function buildApp(deps: AppDependencies = {}) {
const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) }); const options = {
logger: deps.logger ?? true,
...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }),
};
const app: FastifyInstance = deps.https === undefined ? Fastify(options) : Fastify({ ...options, https: deps.https });
// Vite proxies development API requests here, while production and machine-scoped // Vite proxies development API requests here, while production and machine-scoped
// API requests already terminate here, so this is the shared browser HTTP edge. // API requests already terminate here, so this is the shared browser HTTP edge.
await app.register(fastifyCompress, { await app.register(fastifyCompress, {
@@ -214,6 +221,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const invalidatingConfigService = invalidatePiWebStatusOnWrite(configService, piWebStatusCache); const invalidatingConfigService = invalidatePiWebStatusOnWrite(configService, piWebStatusCache);
registerConfigRoutes(app, invalidatingConfigService); registerConfigRoutes(app, invalidatingConfigService);
registerLocalMachineConfigRoutes(app, invalidatingConfigService); registerLocalMachineConfigRoutes(app, invalidatingConfigService);
registerAzureSpeechRoutes(app);
registerAzureSpeechRoutes(app, undefined, "/api/machines/local");
registerMachineRoutes(app, machines); registerMachineRoutes(app, machines);
registerMachinePluginProxyRoutes(app, machines); registerMachinePluginProxyRoutes(app, machines);
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from "vitest";
import Fastify from "fastify";
import { registerAzureSpeechRoutes, type AzureSpeechService } from "./azureSpeechRoutes.js";
function service(): AzureSpeechService {
return {
settings: vi.fn(() => ({ region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", hasApiKey: true })),
update: vi.fn(() => ({ region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", hasApiKey: true })),
token: vi.fn(async () => ({ token: "short-lived-token", region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural" })),
};
}
describe("Azure Speech routes", () => {
it("returns redacted settings and accepts a write-only API key", async () => {
const app = Fastify();
const azure = service();
registerAzureSpeechRoutes(app, azure);
const get = await app.inject({ method: "GET", url: "/api/azure-speech" });
expect(get.statusCode).toBe(200);
expect(get.json()).toEqual({ region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", hasApiKey: true });
expect(get.body).not.toContain("apiKey");
const put = await app.inject({ method: "PUT", url: "/api/azure-speech", payload: { region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", apiKey: "a".repeat(32) } });
expect(put.statusCode).toBe(200);
expect(azure.update).toHaveBeenCalledWith({ region: "eastus", voice: "en-US-Andrew:DragonHDLatestNeural", apiKey: "a".repeat(32) });
expect(put.body).not.toContain("apiKey");
await app.close();
});
it("rejects malformed settings before they reach the service", async () => {
const app = Fastify();
const azure = service();
registerAzureSpeechRoutes(app, azure);
const response = await app.inject({ method: "PUT", url: "/api/azure-speech", payload: { region: "eastus!", voice: "voice" } });
expect(response.statusCode).toBe(400);
expect(azure.update).not.toHaveBeenCalled();
await app.close();
});
});
+135
View File
@@ -0,0 +1,135 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { FastifyInstance } from "fastify";
import { piWebConfigPath } from "../config.js";
import type { AzureSpeechSettings, AzureSpeechSettingsUpdate, AzureSpeechToken } from "../shared/apiTypes.js";
interface AzureSpeechSecret {
region: string;
voice: string;
apiKey?: string;
}
export interface AzureSpeechService {
settings(): AzureSpeechSettings;
update(input: AzureSpeechSettingsUpdate): AzureSpeechSettings;
token(): Promise<AzureSpeechToken>;
}
export function createFileAzureSpeechService(): AzureSpeechService {
const path = join(dirname(piWebConfigPath()), "azure-speech.json");
return {
settings: () => publicSettings(readSecret(path)),
update: (input) => {
const current = readSecret(path);
const next: AzureSpeechSecret = {
region: requireRegion(input.region),
voice: requireVoice(input.voice),
...(input.apiKey === undefined ? (current.apiKey === undefined ? {} : { apiKey: current.apiKey }) : input.apiKey === "" ? {} : { apiKey: requireApiKey(input.apiKey) }),
};
writeSecret(path, next);
return publicSettings(next);
},
token: async () => {
const secret = readSecret(path);
if (secret.apiKey === undefined || secret.region === "") throw new AzureSpeechConfigurationError("Azure Speech is not configured. Add a Speech key and region in Settings → Azure Speech.");
const response = await fetch(`https://${encodeURIComponent(secret.region)}.api.cognitive.microsoft.com/sts/v1.0/issueToken`, {
method: "POST",
headers: { "Ocp-Apim-Subscription-Key": secret.apiKey },
// Let the runtime calculate Content-Length. PI's installed Undici dispatcher
// rejects a manually supplied header, while Azure requires a zero-length POST body.
body: "",
});
if (!response.ok) throw new AzureSpeechTokenError(`Azure Speech token request failed (${String(response.status)}). Check the key and region.`);
const token = (await response.text()).trim();
if (token === "") throw new AzureSpeechTokenError("Azure Speech returned an empty authorization token.");
return { token, region: secret.region, voice: secret.voice };
},
};
}
export function registerAzureSpeechRoutes(app: FastifyInstance, service: AzureSpeechService = createFileAzureSpeechService(), prefix = "/api"): void {
app.get(`${prefix}/azure-speech`, async (_request, reply) => {
try {
return service.settings();
} catch (error) {
return reply.code(500).send({ error: errorMessage(error) });
}
});
app.put<{ Body: unknown }>(`${prefix}/azure-speech`, async (request, reply) => {
try {
return service.update(parseSettingsUpdate(request.body));
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post(`${prefix}/azure-speech/token`, async (_request, reply) => {
try {
return await service.token();
} catch (error) {
const status = error instanceof AzureSpeechConfigurationError ? 400 : error instanceof AzureSpeechTokenError ? 502 : 500;
app.log.error({ err: error }, "Azure Speech token request failed");
return reply.code(status).send({ error: errorMessage(error) });
}
});
}
function readSecret(path: string): AzureSpeechSecret {
if (!existsSync(path)) return { region: "eastus", voice: "" };
const value: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isRecord(value)) throw new Error(`Azure Speech settings must be a JSON object: ${path}`);
return {
region: value["region"] === undefined ? "eastus" : requireRegion(value["region"]),
voice: value["voice"] === undefined ? "" : requireVoice(value["voice"]),
...(value["apiKey"] === undefined ? {} : { apiKey: requireApiKey(value["apiKey"]) }),
};
}
function writeSecret(path: string, secret: AzureSpeechSecret): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, `${JSON.stringify(secret, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
chmodSync(path, 0o600);
}
function publicSettings(secret: AzureSpeechSecret): AzureSpeechSettings {
return { region: secret.region, voice: secret.voice, hasApiKey: secret.apiKey !== undefined };
}
function parseSettingsUpdate(value: unknown): AzureSpeechSettingsUpdate {
if (!isRecord(value)) throw new Error("Azure Speech settings update must be an object");
return {
region: requireRegion(value["region"]),
voice: requireVoice(value["voice"]),
...(value["apiKey"] === undefined ? {} : { apiKey: typeof value["apiKey"] === "string" ? value["apiKey"] : fail("Azure Speech API key must be a string") }),
};
}
function requireRegion(value: unknown): string {
if (typeof value !== "string" || !/^[a-z0-9-]{2,64}$/i.test(value.trim())) throw new Error("Azure Speech region must contain only letters, digits, and hyphens");
return value.trim().toLowerCase();
}
function requireVoice(value: unknown): string {
if (typeof value !== "string" || value.trim().length > 256) throw new Error("Azure Speech voice must be a string up to 256 characters");
return value.trim();
}
function requireApiKey(value: unknown): string {
if (typeof value !== "string" || value.trim().length < 16 || value.trim().length > 512) throw new Error("Azure Speech API key must be between 16 and 512 characters");
return value.trim();
}
function fail(message: string): never {
throw new Error(message);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
class AzureSpeechConfigurationError extends Error {}
class AzureSpeechTokenError extends Error {}
+15 -1
View File
@@ -1,7 +1,21 @@
#!/usr/bin/env node #!/usr/bin/env node
import { readFileSync } from "node:fs";
import { effectivePiWebConfig, maxUploadBytes } from "../config.js"; import { effectivePiWebConfig, maxUploadBytes } from "../config.js";
import { buildApp } from "./app.js"; import { buildApp } from "./app.js";
const { config } = effectivePiWebConfig(); const { config } = effectivePiWebConfig();
const app = await buildApp({ bodyLimit: maxUploadBytes(process.env, config) }); const app = await buildApp({
bodyLimit: maxUploadBytes(process.env, config),
...tlsOptions(process.env),
});
await app.listen({ port: config.port ?? 8504, host: config.host ?? "127.0.0.1" }); await app.listen({ port: config.port ?? 8504, host: config.host ?? "127.0.0.1" });
function tlsOptions(env: NodeJS.ProcessEnv): { https?: { key: Buffer; cert: Buffer } } {
const keyPath = env["PI_WEB_TLS_KEY"];
const certPath = env["PI_WEB_TLS_CERT"];
if ((keyPath === undefined || keyPath === "") && (certPath === undefined || certPath === "")) return {};
if (keyPath === undefined || keyPath === "" || certPath === undefined || certPath === "") {
throw new Error("Set both PI_WEB_TLS_KEY and PI_WEB_TLS_CERT to enable HTTPS.");
}
return { https: { key: readFileSync(keyPath), cert: readFileSync(certPath) } };
}
+20
View File
@@ -77,6 +77,26 @@ export interface PiWebAgentConfig {
dir?: string; dir?: string;
} }
export interface AzureSpeechSettings {
region: string;
voice: string;
/** Deliberately the only key-related field exposed to the browser. */
hasApiKey: boolean;
}
export interface AzureSpeechSettingsUpdate {
region: string;
voice: string;
/** Omit to retain the stored key; an empty string clears it. */
apiKey?: string;
}
export interface AzureSpeechToken {
token: string;
region: string;
voice: string;
}
export interface PiWebConfigValues { export interface PiWebConfigValues {
host?: string; host?: string;
port?: number; port?: number;