mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
234 lines
6.7 KiB
JavaScript
234 lines
6.7 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import test from 'node:test'
|
|
import vm from 'node:vm'
|
|
|
|
const CLIENT = new URL('../plugins/dsh-desktop-voice/client.js', import.meta.url)
|
|
|
|
function loadVoiceClient({ confirmInstall = () => false, engine = 'sensevoice', holdInstall = false } = {}) {
|
|
let plugin
|
|
const registered = new Map()
|
|
const indicatorParts = {
|
|
'.label': { textContent: '' },
|
|
'.kbd': { textContent: '' },
|
|
'.stop': { hidden: false },
|
|
'.progress': { hidden: true, value: 0 },
|
|
'.percent': { hidden: true, textContent: '' },
|
|
}
|
|
const indicator = {
|
|
classList: { toggle() {} },
|
|
querySelector(selector) {
|
|
return indicatorParts[selector]
|
|
},
|
|
}
|
|
const window = {
|
|
__ModuleLoader__: {
|
|
load(definition) {
|
|
plugin = definition.factory((id) => {
|
|
if (id === 'react') return React
|
|
if (id === 'react/jsx-runtime') return jsx
|
|
throw new Error(`unexpected module: ${id}`)
|
|
})
|
|
},
|
|
},
|
|
addEventListener() {},
|
|
confirm: confirmInstall,
|
|
}
|
|
const document = {
|
|
querySelector() {
|
|
return {}
|
|
},
|
|
getElementById() {
|
|
return indicator
|
|
},
|
|
}
|
|
const jsx = {
|
|
jsx(type, props, key) {
|
|
return { type, props: props || {}, key }
|
|
},
|
|
jsxs(type, props, key) {
|
|
return { type, props: props || {}, key }
|
|
},
|
|
}
|
|
let hooks = []
|
|
let hookIndex = 0
|
|
const React = {
|
|
useState(initial) {
|
|
const index = hookIndex++
|
|
if (!(index in hooks)) hooks[index] = typeof initial === 'function' ? initial() : initial
|
|
return [hooks[index], (next) => {
|
|
hooks[index] = typeof next === 'function' ? next(hooks[index]) : next
|
|
}]
|
|
},
|
|
useRef(initial) {
|
|
const index = hookIndex++
|
|
if (!(index in hooks)) hooks[index] = { current: initial }
|
|
return hooks[index]
|
|
},
|
|
useEffect(effect, dependencies) {
|
|
const index = hookIndex++
|
|
const previous = hooks[index]
|
|
const changed = !dependencies || !previous || dependencies.some((value, i) => value !== previous[i])
|
|
hooks[index] = dependencies || null
|
|
if (changed) effect()
|
|
},
|
|
}
|
|
const requests = []
|
|
let micRequests = 0
|
|
const context = {
|
|
AudioContext: function AudioContext() {},
|
|
Blob,
|
|
DataView,
|
|
Float32Array,
|
|
MediaRecorder: undefined,
|
|
URL,
|
|
clearTimeout,
|
|
clearInterval,
|
|
console,
|
|
document,
|
|
encodeURIComponent,
|
|
fetch: async (url, options = {}) => {
|
|
requests.push({ url, method: options.method || 'GET' })
|
|
if (url === '/dsh-desktop/voice') {
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return {
|
|
enabled: true,
|
|
engine,
|
|
dictationMode: 'toggle',
|
|
language: 'zh',
|
|
modelInstalled: false,
|
|
}
|
|
},
|
|
}
|
|
}
|
|
if (url === '/dsh-desktop/voice/model' && (!options.method || options.method === 'GET')) {
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { status: 'installing', percent: 42, stage: '正在下载 SenseVoice 模型…' }
|
|
},
|
|
}
|
|
}
|
|
if (url === '/dsh-desktop/voice/model' && options.method === 'POST') {
|
|
if (holdInstall) return new Promise(() => {})
|
|
return {
|
|
ok: true,
|
|
async json() {
|
|
return { installed: true }
|
|
},
|
|
}
|
|
}
|
|
throw new Error(`unexpected request: ${options.method || 'GET'} ${url}`)
|
|
},
|
|
navigator: {
|
|
language: 'zh-CN',
|
|
platform: 'Linux x86_64',
|
|
mediaDevices: {
|
|
getUserMedia() {
|
|
micRequests += 1
|
|
return new Promise(() => {})
|
|
},
|
|
},
|
|
},
|
|
setInterval,
|
|
setTimeout,
|
|
window,
|
|
}
|
|
vm.runInNewContext(readFileSync(CLIENT, 'utf8'), context, { filename: CLIENT.pathname })
|
|
plugin.apply({
|
|
slots: {
|
|
inject(_name, register) {
|
|
register()
|
|
},
|
|
register(meta, component) {
|
|
registered.set(meta.name, component)
|
|
},
|
|
},
|
|
})
|
|
const MicButton = registered.get('conversation.input.right')
|
|
assert.equal(typeof MicButton, 'function')
|
|
const VoiceSettings = registered.get('settings.section')
|
|
assert.equal(typeof VoiceSettings, 'function')
|
|
const props = {
|
|
inputActions: { setDraft() {} },
|
|
useInput() {
|
|
return ''
|
|
},
|
|
}
|
|
return {
|
|
indicatorParts,
|
|
requests,
|
|
get micRequests() {
|
|
return micRequests
|
|
},
|
|
async renderMicReady() {
|
|
hooks = []
|
|
hookIndex = 0
|
|
MicButton(props)
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
hookIndex = 0
|
|
return MicButton(props)
|
|
},
|
|
async renderSettingsReady() {
|
|
hooks = []
|
|
hookIndex = 0
|
|
VoiceSettings({})
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
hookIndex = 0
|
|
return VoiceSettings({})
|
|
},
|
|
}
|
|
}
|
|
|
|
test('microphone click offers to install a missing SenseVoice model', async () => {
|
|
let prompts = 0
|
|
const harness = loadVoiceClient({
|
|
confirmInstall(message) {
|
|
prompts += 1
|
|
assert.match(message, /SenseVoice/)
|
|
assert.match(message, /安装/)
|
|
return true
|
|
},
|
|
})
|
|
const button = await harness.renderMicReady()
|
|
button.props.onClick()
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
assert.equal(prompts, 1)
|
|
assert.deepEqual(
|
|
harness.requests.filter((request) => request.method === 'POST'),
|
|
[{ url: '/dsh-desktop/voice/model', method: 'POST' }],
|
|
)
|
|
assert.equal(harness.micRequests, 1)
|
|
})
|
|
|
|
function textOf(node) {
|
|
if (node == null || typeof node === 'boolean') return ''
|
|
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
|
if (Array.isArray(node)) return node.map(textOf).join(' ')
|
|
return textOf(node.props && node.props.children)
|
|
}
|
|
|
|
test('OpenAI fields only render for the OpenAI engine', async () => {
|
|
const localSettings = textOf(await loadVoiceClient({ engine: 'sensevoice' }).renderSettingsReady())
|
|
assert.doesNotMatch(localSettings, /接口地址|API 密钥|Whisper 模型/)
|
|
|
|
const openAISettings = textOf(await loadVoiceClient({ engine: 'openai' }).renderSettingsReady())
|
|
assert.match(openAISettings, /接口地址/)
|
|
assert.match(openAISettings, /API 密钥/)
|
|
assert.match(openAISettings, /Whisper 模型/)
|
|
})
|
|
|
|
test('model installation renders live progress', async () => {
|
|
const harness = loadVoiceClient({ confirmInstall: () => true, holdInstall: true })
|
|
const button = await harness.renderMicReady()
|
|
button.props.onClick()
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
assert.equal(harness.indicatorParts['.progress'].hidden, false)
|
|
assert.equal(harness.indicatorParts['.progress'].value, 42)
|
|
assert.equal(harness.indicatorParts['.percent'].textContent, '42%')
|
|
})
|