elec_rag_ui/resources/asr-worker.cjs
ssurui 6f93912c86 feat: 初始提交——elec_rag_ui 独立前端工程
基于 Electron + Vue3 + TypeScript + LanceDB 的本地化 RAG 问答桌面应用。
仅支持 Linux(Kylin V10 / Ubuntu),适配 ER160(揽月 M50 NPU)和 ER25(芯动力 RPP)计算卡。
服务进程管理委托给 er_backend(HTTP 17700),前端专注 RAG 逻辑与用户交互。

包含:
- src/elec_rag_ui 前端工程(Electron + Vue3)
- models/ 本地嵌入模型(bge-small/base/large,ASR,标点恢复,onnx 文件由 Git LFS 管理)
- docs/ 示例文档(用于测试索引与问答)
2026-08-15 19:25:28 +08:00

319 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ASR worker process
*
* 独立进程运行 sherpa-onnx-node,避免与 onnxruntime-node 发生 DLL 冲突。
* 通过 process.send / process.on('message') 与主进程通信。
*/
const { existsSync } = require('fs')
const { join } = require('path')
process.on('uncaughtException', (err) => {
console.error('[asr-worker] 未捕获异常:', err)
if (process.send) {
process.send({ type: 'error', message: `ASR worker 未捕获异常:${err.message || err}` })
}
process.exit(1)
})
const { OnlineRecognizer, OfflinePunctuation } = require('sherpa-onnx-node')
let recognizer = null
let activeStream = null
let loadedModelDir = ''
let punctuation = null
let loadedPunctuationModelDir = ''
let chunkCount = 0
let resultCount = 0
function resolveModelFiles(modelDir) {
if (!modelDir || !existsSync(modelDir)) return null
const encoderCandidates = ['encoder.int8.onnx', 'encoder.onnx']
let encoder = ''
for (const name of encoderCandidates) {
const p = join(modelDir, name)
if (existsSync(p)) {
encoder = p
break
}
}
const decoder = join(modelDir, 'decoder.onnx')
const tokens = join(modelDir, 'tokens.txt')
if (!encoder || !existsSync(decoder) || !existsSync(tokens)) return null
return { encoder, decoder, tokens }
}
function resolvePunctuationModel(modelDir) {
if (!modelDir || !existsSync(modelDir)) return null
const candidates = ['model.int8.onnx', 'model.onnx']
for (const name of candidates) {
const p = join(modelDir, name)
if (existsSync(p)) return p
}
return null
}
function send(type, payload = {}) {
if (process.send) {
process.send({ type, ...payload })
}
}
function addPunctuation(text) {
if (!text || !punctuation) return text
try {
return punctuation.addPunct(text)
} catch (e) {
console.error('[asr-worker] 标点恢复失败:', e)
return text
}
}
const CJK_PUNCTUATION_RE = /[,。!?、;:""''()【】《》]$/
function endsWithPunctuation(text) {
return CJK_PUNCTUATION_RE.test(text) || /[,.!?;:\"'\)\]}$]$/.test(text)
}
function fallbackSegmentPunctuation(text) {
if (!text) return text
if (endsWithPunctuation(text)) return text
// 段末加逗号,表示停顿但未结束
return text + ','
}
function fallbackFinalPunctuation(text) {
if (!text) return text
if (endsWithPunctuation(text)) return text
// 整句结束加句号
return text + '。'
}
function initModel(modelDir, numThreads = 2, punctuationModelDir = '') {
// 加载/重载标点模型(如果配置变化)
if (punctuationModelDir && punctuationModelDir !== loadedPunctuationModelDir) {
const punctModel = resolvePunctuationModel(punctuationModelDir)
if (!punctModel) {
console.warn(`[asr-worker] 标点模型目录不完整,未找到 model.int8.onnx 或 model.onnx:${punctuationModelDir}`)
punctuation = null
loadedPunctuationModelDir = ''
} else {
try {
punctuation = new OfflinePunctuation({
model: {
ctTransformer: punctModel,
numThreads,
provider: 'cpu',
debug: 0
}
})
loadedPunctuationModelDir = punctuationModelDir
console.log(`[asr-worker] 标点模型加载成功:${punctModel}`)
} catch (e) {
console.error('[asr-worker] 加载标点模型失败:', e)
punctuation = null
loadedPunctuationModelDir = ''
}
}
} else if (!punctuationModelDir) {
punctuation = null
loadedPunctuationModelDir = ''
}
if (loadedModelDir === modelDir && recognizer) {
send('ready')
return
}
const files = resolveModelFiles(modelDir)
if (!files) {
send('error', { message: `ASR 模型目录不完整,需包含 encoder(.int8).onnx、decoder.onnx 与 tokens.txt:${modelDir}` })
return
}
if (recognizer) {
try {
if (activeStream) {
activeStream.inputFinished?.()
activeStream = null
}
recognizer = null
} catch (e) {
// ignore
}
}
const config = {
featConfig: {
sampleRate: 16000,
featureDim: 80
},
modelConfig: {
paraformer: {
encoder: files.encoder,
decoder: files.decoder
},
tokens: files.tokens,
numThreads,
provider: 'cpu',
debug: 0,
modelType: 'paraformer'
},
decodingMethod: 'greedy_search',
maxActivePaths: 4,
enableEndpoint: true,
rule1MinTrailingSilence: 800,
rule2MinTrailingSilence: 1200,
rule3MinUtteranceLength: 20000
}
try {
recognizer = new OnlineRecognizer(config)
loadedModelDir = modelDir
send('ready')
} catch (e) {
send('error', { message: `加载 ASR 模型失败:${e.message || e}` })
}
}
function startStream() {
if (!recognizer) {
send('error', { message: 'ASR 模型未加载' })
return
}
if (activeStream) {
try {
activeStream.inputFinished?.()
} catch {
// ignore
}
}
activeStream = recognizer.createStream()
send('streamStarted')
}
function feedChunk(samples) {
if (!recognizer || !activeStream) {
send('error', { message: 'ASR 未初始化或未开始识别流' })
return
}
chunkCount++
const floatSamples = new Float32Array(samples)
const rms = Math.sqrt(floatSamples.reduce((s, v) => s + v * v, 0) / floatSamples.length)
// 每 50 个 chunk 打印一次统计
if (chunkCount % 50 === 1) {
console.log(
`[asr-worker] 收到 chunk #${chunkCount}, 样本数=${floatSamples.length}, rms=${rms.toFixed(5)}`
)
}
try {
activeStream.acceptWaveform({ sampleRate: 16000, samples: floatSamples })
while (recognizer.isReady(activeStream)) {
recognizer.decode(activeStream)
}
const isEndpoint = recognizer.isEndpoint(activeStream)
if (isEndpoint) {
recognizer.reset(activeStream)
}
const result = recognizer.getResult(activeStream)
let text = result.text?.trim() ?? ''
// 端点处对当前段落后处理:优先使用标点模型,否则用简单规则断句
if (text && isEndpoint) {
if (punctuation) {
text = addPunctuation(text)
} else {
text = fallbackSegmentPunctuation(text)
}
}
if (text) {
resultCount++
console.log(`[asr-worker] 识别结果 #${resultCount}: "${text}" isEndpoint=${isEndpoint}`)
}
send('result', { text, isEndpoint })
} catch (e) {
console.error('[asr-worker] 识别失败:', e)
send('error', { message: `识别失败:${e.message || e}` })
}
}
function stopStream() {
if (!recognizer || !activeStream) {
send('stopped', { text: '' })
return
}
try {
const result = recognizer.getResult(activeStream)
let text = result.text?.trim() ?? ''
if (text) {
if (punctuation) {
text = addPunctuation(text)
} else {
text = fallbackFinalPunctuation(text)
}
}
try {
activeStream.inputFinished?.()
} catch {
// ignore
}
activeStream = null
send('stopped', { text })
} catch (e) {
activeStream = null
send('stopped', { text: '' })
send('error', { message: `停止识别失败:${e.message || e}` })
}
}
function unloadModel() {
try {
activeStream = null
recognizer = null
punctuation = null
loadedModelDir = ''
loadedPunctuationModelDir = ''
} catch {
// ignore
}
}
process.on('message', (msg) => {
if (!msg || typeof msg !== 'object') return
switch (msg.type) {
case 'init':
initModel(msg.modelDir, msg.numThreads, msg.punctuationModelDir)
break
case 'startStream':
startStream()
break
case 'feedChunk':
feedChunk(msg.samples)
break
case 'stopStream':
stopStream()
break
case 'unloadModel':
unloadModel()
break
default:
break
}
})
// 保持进程运行,直到主进程发送 unloadModel 或断开连接
process.on('disconnect', () => {
unloadModel()
process.exit(0)
})