fix: support configurable bind host for HTTP API and fix Windows sherpa-onnx PATH

- fix(#547): HTTP API server now supports configurable bind host (default 127.0.0.1)
  Docker/N8N users can set host to 0.0.0.0 in settings to allow container access.
  Adds httpApiHost config key, UI input in settings, and passes host through
  IPC chain (preload -> main -> httpService).

- fix(#546): Add Windows PATH injection for sherpa-onnx native module
  buildTranscribeWorkerEnv() now adds the sherpa-onnx-win-x64 directory to
  PATH on Windows, fixing 'Could not find sherpa-onnx-node' errors caused
  by missing DLL search path in forked worker processes.
This commit is contained in:
hicccc77
2026-03-25 15:10:16 +08:00
parent acec2e95a2
commit 83f50cbaee
8 changed files with 66 additions and 18 deletions

View File

@@ -2605,8 +2605,9 @@ function registerIpcHandlers() {
}) })
// HTTP API 服务 // HTTP API 服务
ipcMain.handle('http:start', async (_, port?: number) => { ipcMain.handle('http:start', async (_, port?: number, host?: string) => {
return httpService.start(port || 5031) const bindHost = typeof host === 'string' && host.trim() ? host.trim() : '127.0.0.1'
return httpService.start(port || 5031, bindHost)
}) })
ipcMain.handle('http:stop', async () => { ipcMain.handle('http:stop', async () => {

View File

@@ -422,7 +422,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
// HTTP API 服务 // HTTP API 服务
http: { http: {
start: (port?: number) => ipcRenderer.invoke('http:start', port), start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
stop: () => ipcRenderer.invoke('http:stop'), stop: () => ipcRenderer.invoke('http:stop'),
status: () => ipcRenderer.invoke('http:status') status: () => ipcRenderer.invoke('http:status')
} }

View File

@@ -54,6 +54,7 @@ interface ConfigSchema {
messagePushEnabled: boolean messagePushEnabled: boolean
httpApiEnabled: boolean httpApiEnabled: boolean
httpApiPort: number httpApiPort: number
httpApiHost: string
httpApiToken: string httpApiToken: string
windowCloseBehavior: 'ask' | 'tray' | 'quit' windowCloseBehavior: 'ask' | 'tray' | 'quit'
quoteLayout: 'quote-top' | 'quote-bottom' quoteLayout: 'quote-top' | 'quote-bottom'
@@ -125,6 +126,7 @@ export class ConfigService {
httpApiToken: '', httpApiToken: '',
httpApiEnabled: false, httpApiEnabled: false,
httpApiPort: 5031, httpApiPort: 5031,
httpApiHost: '127.0.0.1',
messagePushEnabled: false, messagePushEnabled: false,
windowCloseBehavior: 'ask', windowCloseBehavior: 'ask',
quoteLayout: 'quote-top', quoteLayout: 'quote-top',

View File

@@ -101,6 +101,7 @@ class HttpService {
private server: http.Server | null = null private server: http.Server | null = null
private configService: ConfigService private configService: ConfigService
private port: number = 5031 private port: number = 5031
private host: string = '127.0.0.1'
private running: boolean = false private running: boolean = false
private connections: Set<import('net').Socket> = new Set() private connections: Set<import('net').Socket> = new Set()
private messagePushClients: Set<http.ServerResponse> = new Set() private messagePushClients: Set<http.ServerResponse> = new Set()
@@ -114,12 +115,13 @@ class HttpService {
/** /**
* 启动 HTTP 服务 * 启动 HTTP 服务
*/ */
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> { async start(port: number = 5031, host: string = '127.0.0.1'): Promise<{ success: boolean; port?: number; error?: string }> {
if (this.running && this.server) { if (this.running && this.server) {
return { success: true, port: this.port } return { success: true, port: this.port }
} }
this.port = port this.port = port
this.host = host
return new Promise((resolve) => { return new Promise((resolve) => {
this.server = http.createServer((req, res) => this.handleRequest(req, res)) this.server = http.createServer((req, res) => this.handleRequest(req, res))
@@ -153,10 +155,10 @@ class HttpService {
} }
}) })
this.server.listen(this.port, '127.0.0.1', () => { this.server.listen(this.port, this.host, () => {
this.running = true this.running = true
this.startMessagePushHeartbeat() this.startMessagePushHeartbeat()
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`) console.log(`[HttpService] HTTP API server started on http://${this.host}:${this.port}`)
resolve({ success: true, port: this.port }) resolve({ success: true, port: this.port })
}) })
}) })
@@ -225,7 +227,7 @@ class HttpService {
} }
getMessagePushStreamUrl(): string { getMessagePushStreamUrl(): string {
return `http://127.0.0.1:${this.port}/api/v1/push/messages` return `http://${this.host}:${this.port}/api/v1/push/messages`
} }
broadcastMessagePush(payload: Record<string, unknown>): void { broadcastMessagePush(payload: Record<string, unknown>): void {
@@ -250,8 +252,9 @@ class HttpService {
const enabled = this.configService.get('httpApiEnabled') const enabled = this.configService.get('httpApiEnabled')
if (enabled) { if (enabled) {
const port = Number(this.configService.get('httpApiPort')) || 5031 const port = Number(this.configService.get('httpApiPort')) || 5031
const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1'
try { try {
await this.start(port) await this.start(port, host)
console.log(`[HttpService] Auto-started on port ${port}`) console.log(`[HttpService] Auto-started on port ${port}`)
} catch (err) { } catch (err) {
console.error('[HttpService] Auto-start failed:', err) console.error('[HttpService] Auto-start failed:', err)
@@ -314,7 +317,7 @@ class HttpService {
return return
} }
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`) const url = new URL(req.url || '/', `http://${this.host}:${this.port}`)
const pathname = url.pathname const pathname = url.pathname
try { try {
@@ -961,7 +964,7 @@ class HttpService {
parsedContent: msg.parsedContent, parsedContent: msg.parsedContent,
mediaType: media?.kind, mediaType: media?.kind,
mediaFileName: media?.fileName, mediaFileName: media?.fileName,
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined, mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
mediaLocalPath: media?.fullPath mediaLocalPath: media?.fullPath
} }
} }
@@ -1231,7 +1234,7 @@ class HttpService {
type: this.mapMessageType(msg.localType, msg), type: this.mapMessageType(msg.localType, msg),
content: this.getMessageContent(msg), content: this.getMessageContent(msg),
platformMessageId: msg.serverId ? String(msg.serverId) : undefined, platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
} }
}) })

View File

@@ -75,6 +75,14 @@ export class VoiceTranscribeService {
if (candidates.length === 0) { if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
} }
} else if (process.platform === 'win32') {
// Windows: 把 sherpa-onnx DLL 所在目录加到 PATH否则 native module 找不到依赖
const existing = env['PATH'] || ''
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
env['PATH'] = Array.from(new Set(merged)).join(';')
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
} }
return env return env

View File

@@ -190,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
// HTTP API 设置 state // HTTP API 设置 state
const [httpApiEnabled, setHttpApiEnabled] = useState(false) const [httpApiEnabled, setHttpApiEnabled] = useState(false)
const [httpApiPort, setHttpApiPort] = useState(5031) const [httpApiPort, setHttpApiPort] = useState(5031)
const [httpApiHost, setHttpApiHost] = useState('127.0.0.1')
const [httpApiRunning, setHttpApiRunning] = useState(false) const [httpApiRunning, setHttpApiRunning] = useState(false)
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('') const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
const [isTogglingApi, setIsTogglingApi] = useState(false) const [isTogglingApi, setIsTogglingApi] = useState(false)
@@ -349,6 +350,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const savedApiPort = await configService.getHttpApiPort() const savedApiPort = await configService.getHttpApiPort()
if (savedApiPort) setHttpApiPort(savedApiPort) if (savedApiPort) setHttpApiPort(savedApiPort)
const savedApiHost = await configService.getHttpApiHost()
if (savedApiHost) setHttpApiHost(savedApiHost)
setAuthEnabled(savedAuthEnabled) setAuthEnabled(savedAuthEnabled)
setAuthUseHello(savedAuthUseHello) setAuthUseHello(savedAuthUseHello)
setIsLockMode(savedIsLockMode) setIsLockMode(savedIsLockMode)
@@ -1871,7 +1875,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setShowApiWarning(false) setShowApiWarning(false)
setIsTogglingApi(true) setIsTogglingApi(true)
try { try {
const result = await window.electronAPI.http.start(httpApiPort) const result = await window.electronAPI.http.start(httpApiPort, httpApiHost)
if (result.success) { if (result.success) {
setHttpApiRunning(true) setHttpApiRunning(true)
if (result.port) setHttpApiPort(result.port) if (result.port) setHttpApiPort(result.port)
@@ -1891,7 +1895,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
} }
const handleCopyApiUrl = () => { const handleCopyApiUrl = () => {
const url = `http://127.0.0.1:${httpApiPort}` const url = `http://${httpApiHost}:${httpApiPort}`
navigator.clipboard.writeText(url) navigator.clipboard.writeText(url)
showMessage('已复制 API 地址', true) showMessage('已复制 API 地址', true)
} }
@@ -1923,6 +1927,26 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
</div> </div>
<div className="form-group">
<label></label>
<span className="form-hint">
API <code>127.0.0.1</code> 访Docker/N8N <code>0.0.0.0</code> 访 Token
</span>
<input
type="text"
className="field-input"
value={httpApiHost}
placeholder="127.0.0.1"
onChange={(e) => {
const host = e.target.value.trim() || '127.0.0.1'
setHttpApiHost(host)
scheduleConfigSave('httpApiHost', () => configService.setHttpApiHost(host))
}}
disabled={httpApiRunning}
style={{ width: 180, fontFamily: 'monospace' }}
/>
</div>
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint">API 1024-65535</span> <span className="form-hint">API 1024-65535</span>
@@ -1980,7 +2004,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<input <input
type="text" type="text"
className="field-input" className="field-input"
value={`http://127.0.0.1:${httpApiPort}`} value={`http://${httpApiHost}:${httpApiPort}`}
readOnly readOnly
/> />
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制"> <button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制">
@@ -2029,13 +2053,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<input <input
type="text" type="text"
className="field-input" className="field-input"
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`} value={`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`}
readOnly readOnly
/> />
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`) navigator.clipboard.writeText(`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`)
showMessage('已复制推送地址', true) showMessage('已复制推送地址', true)
}} }}
title="复制" title="复制"
@@ -2052,7 +2076,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="api-item"> <div className="api-item">
<div className="api-endpoint"> <div className="api-endpoint">
<span className="method get">GET</span> <span className="method get">GET</span>
<code>{`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}</code> <code>{`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages`}</code>
</div> </div>
<p className="api-desc"> SSE `messageKey` </p> <p className="api-desc"> SSE `messageKey` </p>
<div className="api-params"> <div className="api-params">

View File

@@ -67,6 +67,7 @@ export const CONFIG_KEYS = {
HTTP_API_TOKEN: 'httpApiToken', HTTP_API_TOKEN: 'httpApiToken',
HTTP_API_ENABLED: 'httpApiEnabled', HTTP_API_ENABLED: 'httpApiEnabled',
HTTP_API_PORT: 'httpApiPort', HTTP_API_PORT: 'httpApiPort',
HTTP_API_HOST: 'httpApiHost',
MESSAGE_PUSH_ENABLED: 'messagePushEnabled', MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
QUOTE_LAYOUT: 'quoteLayout', QUOTE_LAYOUT: 'quoteLayout',
@@ -1509,3 +1510,12 @@ export async function getHttpApiPort(): Promise<number> {
export async function setHttpApiPort(port: number): Promise<void> { export async function setHttpApiPort(port: number): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_PORT, port) await config.set(CONFIG_KEYS.HTTP_API_PORT, port)
} }
export async function getHttpApiHost(): Promise<string> {
const value = await config.get(CONFIG_KEYS.HTTP_API_HOST)
return typeof value === 'string' && value.trim() ? value.trim() : '127.0.0.1'
}
export async function setHttpApiHost(host: string): Promise<void> {
await config.set(CONFIG_KEYS.HTTP_API_HOST, host)
}

View File

@@ -838,7 +838,7 @@ export interface ElectronAPI {
getLogs: () => Promise<string[]> getLogs: () => Promise<string[]>
} }
http: { http: {
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }> start: (port?: number, host?: string) => Promise<{ success: boolean; port?: number; error?: string }>
stop: () => Promise<{ success: boolean }> stop: () => Promise<{ success: boolean }>
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }> status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
} }