@sakka6868/dsh-voice-input 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/lib/client.js +266 -0
- package/lib/index.js +135 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sakka6868
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @sakka6868/dsh-voice-input
|
|
2
|
+
|
|
3
|
+
> DeepSeek Harness Web UI 的「语音输入」插件 —— 点一下麦克风(或按 `Ctrl+Shift+M`),边说边把话实时变成输入框里的文字,停止后自动校对。
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## ✨ 核心亮点
|
|
10
|
+
|
|
11
|
+
- **实时转录** — 使用浏览器原生 Web Speech API(Edge 中后端即**微软语音服务**),说话时文字逐字出现在输入框,无录音文件、无本地模型、无等待;
|
|
12
|
+
- **零依赖零配置** — 不需要 API Key、不下载模型、不占用 CPU;
|
|
13
|
+
- **自动校对** — 停止说话后静默调用当前会话模型校对:修正错别字、同音/近音转录偏差、繁体转简体、理顺标点;**你手动改过输入框就不会覆盖**;
|
|
14
|
+
- **快捷键** — `Ctrl+Shift+M` 一键开始/停止语音输入;
|
|
15
|
+
- **位置贴合** — 麦克风按钮固定在发送按钮左侧(flex `order:1`,与内置控件同排);
|
|
16
|
+
- **无打扰 UI** — 录音过程不弹出任何面板/浮层,输入框里的文字就是全部反馈;
|
|
17
|
+
- **干净可逆** — 只写输入框草稿,是否发送完全由你决定;卸载即移除按钮、快捷键与样式。
|
|
18
|
+
|
|
19
|
+
## 安装与使用
|
|
20
|
+
|
|
21
|
+
### 方式一:静态插件(npm 包,推荐)
|
|
22
|
+
|
|
23
|
+
```powershell
|
|
24
|
+
dsh plugin --profile web add @sakka6868/dsh-voice-input
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
然后在 `~/.dsh/profiles/web/cordis.patch.yml` 注册插件行:
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
- insert:
|
|
31
|
+
- id: dsh-voice-input
|
|
32
|
+
name: '@sakka6868/dsh-voice-input'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
重启 DSH web 服务后,输入栏发送按钮左侧出现麦克风按钮。
|
|
36
|
+
|
|
37
|
+
### 方式二:Dynamic 模式(开发/尝鲜)
|
|
38
|
+
|
|
39
|
+
在 DSH 的 Cordis 动态插件面板:
|
|
40
|
+
|
|
41
|
+
1. 新建插件(idPrefix 如 `voice`);
|
|
42
|
+
2. `code.host` 粘贴 `lib/index.js` 中 `proofread` 相关逻辑(或改用 `harness.handle('voice.proofread', ...)` 形态);
|
|
43
|
+
3. `code.client` 粘贴 `lib/client.js` 中组件与状态机逻辑(把 `fetch` 换回 `host.call`、把 `<style>` 注入换回 `styles.insert`、把原生定时器换回 `ctx.timeout`);
|
|
44
|
+
4. 激活并授权。
|
|
45
|
+
|
|
46
|
+
## 使用说明
|
|
47
|
+
|
|
48
|
+
| 操作 | 效果 |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| 点击 🎤 或按 `Ctrl+Shift+M` | 开始语音识别(首次会请求麦克风权限),文字实时写入输入框 |
|
|
51
|
+
| 再次点击红色停止钮或 `Ctrl+Shift+M` | 结束识别,静默校对后回填(未改动则替换,改动过则保留你的版本) |
|
|
52
|
+
| 停顿约 1.5 秒 | 自动结束识别 |
|
|
53
|
+
| 按原有发送按钮 | 发送输入框中的文字 |
|
|
54
|
+
|
|
55
|
+
识别语言固定为 `zh-CN`(可在 `lib/client.js` 的 `createRecognition()` 中修改 `rec.lang`)。
|
|
56
|
+
|
|
57
|
+
## 工作原理
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
浏览器 Web Speech API (实时识别, Edge→微软语音服务)
|
|
61
|
+
│ interim/final 结果
|
|
62
|
+
▼
|
|
63
|
+
conversation.input.right 麦克风按钮 → inputActions.setDraft(文字) ← 实时写入输入框
|
|
64
|
+
│ 停止 / 停顿 1.5s
|
|
65
|
+
▼
|
|
66
|
+
POST /dsh-voice-input/proofread (Host 半, webServer 路由)
|
|
67
|
+
│ ctx.get('llm') + ctx.get('agentDefaultModel')
|
|
68
|
+
▼
|
|
69
|
+
校对文本 → 回填输入框(仅在草稿未被用户修改时)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- **Client 半**(`lib/client.js`):module-loader factory 包,注册 `conversation.input.right`(按钮)与 `conversation.input.dock`(错误提示),快捷键监听挂在 `document`,卸载时清理。
|
|
73
|
+
- **Host 半**(`lib/index.js`):一个 `webServer` exact POST 路由,用会话默认模型做一次低温度校对调用;任何服务缺失或调用失败都退化为“原样返回转写文本”,不会打断输入。
|
|
74
|
+
|
|
75
|
+
## 兼容性与限制
|
|
76
|
+
|
|
77
|
+
- **Edge / Chrome**:完整可用(Web Speech API 需要联网,识别在浏览器厂商的语音服务端完成);
|
|
78
|
+
- **Firefox**:不支持 `SpeechRecognition`,按钮会提示改用 Edge/Chrome;
|
|
79
|
+
- **Safari**:部分版本支持 `webkitSpeechRecognition`,`continuous` 行为可能不同;
|
|
80
|
+
- 离线环境无法识别(语音服务在云端);
|
|
81
|
+
- 校对依赖当前会话是否配置了可用模型;未配置时保留原始转写。
|
|
82
|
+
|
|
83
|
+
## 许可
|
|
84
|
+
|
|
85
|
+
[MIT](LICENSE)
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// dsh-voice-input — static client half (module-loader factory bundle).
|
|
2
|
+
//
|
|
3
|
+
// Adapted from the dynamic plugin form for the STATIC plugin shape:
|
|
4
|
+
// - registered via window.__ModuleLoader__.load({ id, factory });
|
|
5
|
+
// - React arrives through require('react');
|
|
6
|
+
// - the dynamic plugin's host.call('voice.proofread') is replaced by
|
|
7
|
+
// fetch('/dsh-voice-input/proofread');
|
|
8
|
+
// - styles use the official per-plugin <style data-plugin-css> pattern
|
|
9
|
+
// instead of the dynamic styles Builtin;
|
|
10
|
+
// - native browser timers replace the dynamic ctx.interval/ctx.timeout.
|
|
11
|
+
//
|
|
12
|
+
// Component logic, state machine, slot ids, button placement (flex order 1)
|
|
13
|
+
// and CSS rules are unchanged from the dynamic form.
|
|
14
|
+
window.__ModuleLoader__.load({
|
|
15
|
+
id: 'dsh-voice-input',
|
|
16
|
+
factory: (require) => {
|
|
17
|
+
var module = { exports: {} };
|
|
18
|
+
var exports = module.exports;
|
|
19
|
+
var React = require('react');
|
|
20
|
+
|
|
21
|
+
const STYLE_TAG_ID = 'dsh-voice-input';
|
|
22
|
+
const PROOFREAD_URL = '/dsh-voice-input/proofread';
|
|
23
|
+
|
|
24
|
+
const CSS = [
|
|
25
|
+
'.vc-cell{order:1}',
|
|
26
|
+
'.vc-btn{width:32px;height:32px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.4));background:transparent;color:var(--dsw-alias-label-secondary,#666);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background .15s ease,color .15s ease,border-color .15s ease}',
|
|
27
|
+
'.vc-btn:hover{background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.06))}',
|
|
28
|
+
'.vc-btn:disabled{opacity:.45;cursor:default}',
|
|
29
|
+
'.vc-btn.vc-rec{border-color:transparent;background:var(--dsw-alias-state-error-primary,#e5484d);color:#fff}',
|
|
30
|
+
'.vc-btn.vc-rec:hover{background:var(--dsw-alias-state-error-primary,#e5484d);filter:brightness(1.1)}',
|
|
31
|
+
'.vc-spin{width:14px;height:14px;border-radius:50%;border:2px solid var(--dsw-alias-border-l2,rgba(127,127,127,.4));border-top-color:var(--dsw-alias-brand-primary,#4c6ef5);animation:vc-spin .7s linear infinite}',
|
|
32
|
+
'@keyframes vc-spin{to{transform:rotate(360deg)}}',
|
|
33
|
+
'.vc-dock-line{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;border-radius:999px;background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));box-shadow:0 2px 10px rgba(0,0,0,.06);font-size:12px;color:var(--dsw-alias-label-secondary,#666);max-width:560px}',
|
|
34
|
+
'.vc-error{color:var(--dsw-alias-state-error-primary,#e5484d)}',
|
|
35
|
+
].join('\n');
|
|
36
|
+
|
|
37
|
+
let styleTag = document.querySelector('style[data-plugin-css="' + STYLE_TAG_ID + '"]');
|
|
38
|
+
if (styleTag === null) {
|
|
39
|
+
styleTag = document.createElement('style');
|
|
40
|
+
styleTag.dataset.pluginCss = STYLE_TAG_ID;
|
|
41
|
+
styleTag.textContent = CSS;
|
|
42
|
+
document.head.appendChild(styleTag);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const svgBase = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' };
|
|
46
|
+
|
|
47
|
+
function MicIcon() {
|
|
48
|
+
return React.createElement('svg', svgBase,
|
|
49
|
+
React.createElement('path', { d: 'M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z' }),
|
|
50
|
+
React.createElement('path', { d: 'M19 10v2a7 7 0 0 1-14 0v-2' }),
|
|
51
|
+
React.createElement('line', { x1: 12, y1: 19, x2: 12, y2: 23 }))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function StopIcon() {
|
|
55
|
+
return React.createElement('svg', { width: 13, height: 13, viewBox: '0 0 24 24', fill: 'currentColor' },
|
|
56
|
+
React.createElement('rect', { x: 5, y: 5, width: 14, height: 14, rx: 3 }))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Host-side proofread; any failure degrades to the raw transcript. */
|
|
60
|
+
function proofreadRemote(text) {
|
|
61
|
+
return fetch(PROOFREAD_URL, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ text: text }),
|
|
65
|
+
}).then((res) => res.json()).then((reply) => {
|
|
66
|
+
if (reply && typeof reply.text === 'string' && reply.text.trim().length > 0) return reply.text.trim()
|
|
67
|
+
return text
|
|
68
|
+
}).catch(() => text)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function apply(ctx) {
|
|
72
|
+
const slots = ctx.get('slots')
|
|
73
|
+
if (slots === undefined) return
|
|
74
|
+
|
|
75
|
+
const TRANSITIONS = {
|
|
76
|
+
IDLE: { start: { to: 'REQUESTING', effect: 'startRec' } },
|
|
77
|
+
REQUESTING: { started: { to: 'RECORDING' }, denied: { to: 'ERROR', effect: 'recError' } },
|
|
78
|
+
RECORDING: { stop: { to: 'IDLE', effect: 'stopRec' }, ended: { to: 'IDLE', effect: 'autoFinalize' } },
|
|
79
|
+
ERROR: { dismiss: { to: 'IDLE' } },
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const store = {
|
|
83
|
+
state: 'IDLE',
|
|
84
|
+
errorText: '',
|
|
85
|
+
sessionId: null,
|
|
86
|
+
inputActions: null,
|
|
87
|
+
baseDraft: '',
|
|
88
|
+
finalText: '',
|
|
89
|
+
currentDraft: null,
|
|
90
|
+
stopDraft: null,
|
|
91
|
+
}
|
|
92
|
+
const listeners = []
|
|
93
|
+
let recognition = null
|
|
94
|
+
let speechEndTimer = null
|
|
95
|
+
|
|
96
|
+
function notify() { listeners.forEach((fn) => { try { fn() } catch (_e) { /* noop */ } }) }
|
|
97
|
+
function set(next, extra) { if (extra !== undefined) Object.assign(store, extra); store.state = next; notify() }
|
|
98
|
+
function dispatch(event) {
|
|
99
|
+
const row = TRANSITIONS[store.state] && TRANSITIONS[store.state][event]
|
|
100
|
+
if (row === undefined) return false
|
|
101
|
+
if (row.effect !== undefined && EFFECTS[row.effect] !== undefined) EFFECTS[row.effect]()
|
|
102
|
+
else set(row.to)
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createRecognition() {
|
|
107
|
+
const SR = typeof SpeechRecognition !== 'undefined' ? SpeechRecognition : (typeof webkitSpeechRecognition !== 'undefined' ? webkitSpeechRecognition : null)
|
|
108
|
+
if (SR === null) return null
|
|
109
|
+
const rec = new SR()
|
|
110
|
+
rec.lang = 'zh-CN'
|
|
111
|
+
rec.continuous = true
|
|
112
|
+
rec.interimResults = true
|
|
113
|
+
return rec
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function setDraft(text) {
|
|
117
|
+
const a = store.inputActions
|
|
118
|
+
if (a && typeof a.setDraft === 'function') { try { a.setDraft(text) } catch (_e) { /* noop */ } }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function finishSilently() {
|
|
122
|
+
set('IDLE')
|
|
123
|
+
const text = store.finalText.trim()
|
|
124
|
+
store.stopDraft = typeof store.currentDraft === 'string' ? store.currentDraft : text
|
|
125
|
+
if (text.length === 0) return
|
|
126
|
+
proofreadRemote(text).then((cleaned) => {
|
|
127
|
+
// Only replace the draft when the user did not touch it meanwhile.
|
|
128
|
+
if (store.state === 'IDLE' && store.currentDraft === store.stopDraft) setDraft(cleaned)
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const EFFECTS = {
|
|
133
|
+
startRec() {
|
|
134
|
+
const rec = createRecognition()
|
|
135
|
+
if (rec === null) { set('ERROR', { errorText: '当前浏览器不支持语音识别(请使用 Edge 或 Chrome)' }); return }
|
|
136
|
+
recognition = rec
|
|
137
|
+
store.finalText = ''
|
|
138
|
+
store.baseDraft = typeof store.currentDraft === 'string' ? store.currentDraft : ''
|
|
139
|
+
store.stopDraft = null
|
|
140
|
+
rec.onstart = () => dispatch('started')
|
|
141
|
+
rec.onresult = (e) => {
|
|
142
|
+
if (store.state !== 'RECORDING' && store.state !== 'REQUESTING') return
|
|
143
|
+
if (speechEndTimer !== null) { clearTimeout(speechEndTimer); speechEndTimer = null }
|
|
144
|
+
let interim = ''
|
|
145
|
+
for (let i = e.resultIndex; i < e.results.length; i += 1) {
|
|
146
|
+
const r = e.results[i]
|
|
147
|
+
if (r.isFinal) store.finalText += r[0].transcript
|
|
148
|
+
else interim += r[0].transcript
|
|
149
|
+
}
|
|
150
|
+
const base = store.baseDraft.trim()
|
|
151
|
+
const body = (store.finalText + interim).trim()
|
|
152
|
+
setDraft(base.length > 0 ? base + ' ' + body : body)
|
|
153
|
+
}
|
|
154
|
+
rec.onspeechend = () => {
|
|
155
|
+
if (speechEndTimer !== null) clearTimeout(speechEndTimer)
|
|
156
|
+
speechEndTimer = setTimeout(() => {
|
|
157
|
+
speechEndTimer = null
|
|
158
|
+
if (store.state === 'RECORDING') { try { rec.stop() } catch (_e) { /* noop */ } }
|
|
159
|
+
}, 1500)
|
|
160
|
+
}
|
|
161
|
+
rec.onerror = (e) => {
|
|
162
|
+
const err = e && e.error
|
|
163
|
+
if (err === 'not-allowed' || err === 'service-not-allowed') {
|
|
164
|
+
set('ERROR', { errorText: '麦克风权限被拒绝,请在浏览器设置中允许' })
|
|
165
|
+
} else if (err === 'no-speech') {
|
|
166
|
+
if (store.state === 'RECORDING' || store.state === 'REQUESTING') set('IDLE')
|
|
167
|
+
} else if (err === 'aborted') {
|
|
168
|
+
/* expected on manual stop */
|
|
169
|
+
} else if (store.state === 'RECORDING' || store.state === 'REQUESTING') {
|
|
170
|
+
set('ERROR', { errorText: '语音识别出错:' + (err || 'unknown') })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
rec.onend = () => {
|
|
174
|
+
if (speechEndTimer !== null) { clearTimeout(speechEndTimer); speechEndTimer = null }
|
|
175
|
+
recognition = null
|
|
176
|
+
if (store.state === 'RECORDING') { dispatch('ended'); return }
|
|
177
|
+
if (store.state === 'REQUESTING') set('IDLE')
|
|
178
|
+
}
|
|
179
|
+
set('REQUESTING')
|
|
180
|
+
try { rec.start() } catch (_e) { set('ERROR', { errorText: '语音识别启动失败' }) }
|
|
181
|
+
},
|
|
182
|
+
recError() { /* errorText already set */ },
|
|
183
|
+
stopRec() {
|
|
184
|
+
if (recognition !== null) { try { recognition.stop() } catch (_e) { /* noop */ } }
|
|
185
|
+
finishSilently()
|
|
186
|
+
},
|
|
187
|
+
autoFinalize() {
|
|
188
|
+
finishSilently()
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function handleKey(e) {
|
|
193
|
+
if (!e) return
|
|
194
|
+
const key = String(e.key || '').toLowerCase()
|
|
195
|
+
const code = String(e.code || '').toLowerCase()
|
|
196
|
+
const isM = key === 'm' || code === 'keym'
|
|
197
|
+
if (!(e.ctrlKey && e.shiftKey && isM)) return
|
|
198
|
+
if (typeof store.sessionId !== 'string' || store.sessionId.length === 0) return
|
|
199
|
+
if (e.preventDefault) { try { e.preventDefault() } catch (_e) { /* noop */ } }
|
|
200
|
+
if (store.state === 'IDLE' || store.state === 'ERROR') dispatch('start')
|
|
201
|
+
else if (store.state === 'RECORDING') dispatch('stop')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
ctx.effect(() => () => {
|
|
205
|
+
if (speechEndTimer !== null) { clearTimeout(speechEndTimer); speechEndTimer = null }
|
|
206
|
+
if (recognition !== null) { try { recognition.abort() } catch (_e) { /* noop */ } recognition = null }
|
|
207
|
+
listeners.length = 0
|
|
208
|
+
store.state = 'IDLE'
|
|
209
|
+
})
|
|
210
|
+
document.addEventListener('keydown', handleKey)
|
|
211
|
+
ctx.effect(() => () => document.removeEventListener('keydown', handleKey))
|
|
212
|
+
|
|
213
|
+
function useStore() {
|
|
214
|
+
const [tick, force] = React.useState(0)
|
|
215
|
+
React.useEffect(() => {
|
|
216
|
+
const cb = () => force((n) => n + 1)
|
|
217
|
+
listeners.push(cb)
|
|
218
|
+
return () => { const i = listeners.indexOf(cb); if (i >= 0) listeners.splice(i, 1) }
|
|
219
|
+
}, [])
|
|
220
|
+
return store
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function VoiceButton(props) {
|
|
224
|
+
const s = useStore()
|
|
225
|
+
if (props) {
|
|
226
|
+
if (typeof props.sessionId === 'string') store.sessionId = props.sessionId
|
|
227
|
+
if (props.inputActions && typeof props.inputActions.setDraft === 'function') store.inputActions = props.inputActions
|
|
228
|
+
if (typeof props.useInput === 'function') {
|
|
229
|
+
try {
|
|
230
|
+
const inputState = props.useInput()
|
|
231
|
+
if (inputState && typeof inputState.draft === 'string') store.currentDraft = inputState.draft
|
|
232
|
+
} catch (_e) { /* noop */ }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const st = s.state
|
|
236
|
+
if (st === 'IDLE' || st === 'ERROR') {
|
|
237
|
+
return React.createElement('button', { className: 'vc-btn vc-cell', title: '语音输入(Ctrl+Shift+M)', onClick: () => dispatch('start') }, React.createElement(MicIcon, null))
|
|
238
|
+
}
|
|
239
|
+
if (st === 'RECORDING') {
|
|
240
|
+
return React.createElement('button', { className: 'vc-btn vc-rec vc-cell', title: '停止(Ctrl+Shift+M)', onClick: () => dispatch('stop') }, React.createElement(StopIcon, null))
|
|
241
|
+
}
|
|
242
|
+
return React.createElement('span', { className: 'vc-spin vc-cell', style: { display: 'inline-flex', margin: 6 } })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function VoiceDock() {
|
|
246
|
+
const s = useStore()
|
|
247
|
+
if (s.state !== 'ERROR') return null
|
|
248
|
+
return React.createElement('div', { className: 'vc-dock-line vc-error' }, s.errorText || '出错了')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
slots.inject('conversation.input.right', () => slots.register(
|
|
252
|
+
{ name: 'conversation.input.right', id: 'voice-record', order: 10, label: '语音输入' },
|
|
253
|
+
(props) => React.createElement(VoiceButton, props),
|
|
254
|
+
))
|
|
255
|
+
slots.inject('conversation.input.dock', () => slots.register(
|
|
256
|
+
{ name: 'conversation.input.dock', id: 'voice-dock', order: 0, label: '语音识别错误' },
|
|
257
|
+
(props) => React.createElement(VoiceDock, props),
|
|
258
|
+
))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
exports.apply = apply;
|
|
262
|
+
exports.inject = ['slots'];
|
|
263
|
+
|
|
264
|
+
return module.exports;
|
|
265
|
+
},
|
|
266
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// dsh-voice-input — static host half (cordis plugin, ESM).
|
|
2
|
+
//
|
|
3
|
+
// Adapted from the dynamic plugin form for the STATIC plugin shape:
|
|
4
|
+
// - the dynamic plugin's harness.handle('voice.proofread') RPC is replaced
|
|
5
|
+
// by a webServer exact POST route (/dsh-voice-input/proofread);
|
|
6
|
+
// - the proofread prompt, stream collection and degradation semantics are
|
|
7
|
+
// kept identical to the dynamic form: any missing service, missing model
|
|
8
|
+
// or failed call degrades to "return the raw transcript unchanged".
|
|
9
|
+
//
|
|
10
|
+
// The route is deliberately small: the Client half owns speech recognition
|
|
11
|
+
// (browser Web Speech API) and only asks the Host to proofread one string.
|
|
12
|
+
export const name = 'dsh-voice-input'
|
|
13
|
+
|
|
14
|
+
/** Hard dependency: the host HTTP carrier that owns the route table. */
|
|
15
|
+
export const inject = ['webServer']
|
|
16
|
+
|
|
17
|
+
const PROOFREAD_SYSTEM = [
|
|
18
|
+
'你是专业中文校对助手。请对用户提供的语音转写文本进行校对:',
|
|
19
|
+
'1. 修正错别字;',
|
|
20
|
+
'2. 修正因读音相同或相近导致的转录偏差;',
|
|
21
|
+
'3. 将繁体字统一转换为简体中文;',
|
|
22
|
+
'4. 理顺标点符号与断句;',
|
|
23
|
+
'5. 使表达通顺自然。',
|
|
24
|
+
'要求:不改变原意、不增删实质信息、不添加解释或评论;只输出校对后的文本本身。',
|
|
25
|
+
].join('')
|
|
26
|
+
|
|
27
|
+
/** Collect the request body as UTF-8 text (bounded to 256 KiB). */
|
|
28
|
+
function readBody(req) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const chunks = []
|
|
31
|
+
let size = 0
|
|
32
|
+
req.on('data', (chunk) => {
|
|
33
|
+
size += chunk.length
|
|
34
|
+
if (size > 262144) {
|
|
35
|
+
reject(new Error('body too large'))
|
|
36
|
+
req.destroy()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
chunks.push(chunk)
|
|
40
|
+
})
|
|
41
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
42
|
+
req.on('error', reject)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sendJson(res, status, data) {
|
|
47
|
+
if (res.headersSent) return
|
|
48
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
49
|
+
res.end(JSON.stringify(data))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Proofread one transcript with the session's default model.
|
|
54
|
+
* Never fails the caller: on any degradation the raw text is returned.
|
|
55
|
+
*/
|
|
56
|
+
async function proofread(ctx, text) {
|
|
57
|
+
if (text === '') return { ok: true, text: '', skipped: true }
|
|
58
|
+
const llm = ctx.get('llm')
|
|
59
|
+
if (llm === undefined) return { ok: true, text: text, skipped: true }
|
|
60
|
+
const model = ctx.get('agentDefaultModel')
|
|
61
|
+
let provider = ''
|
|
62
|
+
let modelId = ''
|
|
63
|
+
try {
|
|
64
|
+
if (model !== undefined) {
|
|
65
|
+
const sel = model.currentSelection()
|
|
66
|
+
if (sel && typeof sel.provider === 'string') provider = sel.provider
|
|
67
|
+
if (sel && typeof sel.model === 'string') modelId = sel.model
|
|
68
|
+
}
|
|
69
|
+
} catch (_e) { /* fall through to directory lookup */ }
|
|
70
|
+
if (provider === '') {
|
|
71
|
+
try {
|
|
72
|
+
const ps = llm.listProviders()
|
|
73
|
+
if (ps && ps.length > 0 && ps[0] && typeof ps[0].id === 'string') provider = ps[0].id
|
|
74
|
+
} catch (_e) { /* noop */ }
|
|
75
|
+
}
|
|
76
|
+
if (provider !== '' && modelId === '') {
|
|
77
|
+
try {
|
|
78
|
+
const ms = await llm.listModels(provider)
|
|
79
|
+
if (ms && ms.length > 0 && ms[0] && typeof ms[0].id === 'string') modelId = ms[0].id
|
|
80
|
+
} catch (_e) { /* noop */ }
|
|
81
|
+
}
|
|
82
|
+
if (provider === '' || modelId === '') return { ok: true, text: text, skipped: true }
|
|
83
|
+
try {
|
|
84
|
+
const stream = llm.stream({
|
|
85
|
+
provider: provider,
|
|
86
|
+
model: modelId,
|
|
87
|
+
system: PROOFREAD_SYSTEM,
|
|
88
|
+
temperature: 0,
|
|
89
|
+
maxTokens: 4000,
|
|
90
|
+
messages: [{
|
|
91
|
+
id: 'voice-proof-user',
|
|
92
|
+
role: 'user',
|
|
93
|
+
content: [{ type: 'text', text: text }],
|
|
94
|
+
source: { kind: 'plugin', plugin: 'dsh-voice-input' },
|
|
95
|
+
}],
|
|
96
|
+
})
|
|
97
|
+
let out = ''
|
|
98
|
+
for await (const chunk of stream) {
|
|
99
|
+
if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') out += chunk.text
|
|
100
|
+
else if (chunk && chunk.type === 'block-end' && chunk.block && chunk.block.type === 'text' && typeof chunk.block.text === 'string') out = chunk.block.text
|
|
101
|
+
}
|
|
102
|
+
const cleaned = out.trim()
|
|
103
|
+
return cleaned.length > 0 ? { ok: true, text: cleaned, skipped: false } : { ok: true, text: text, skipped: true }
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
text: text,
|
|
108
|
+
skipped: true,
|
|
109
|
+
error: (err && err.message) ? String(err.message) : String(err),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function apply(ctx) {
|
|
115
|
+
ctx.effect(() => ctx.webServer.register({
|
|
116
|
+
kind: 'exact',
|
|
117
|
+
path: '/dsh-voice-input/proofread',
|
|
118
|
+
handler: async (req, res) => {
|
|
119
|
+
if (req.method !== 'POST') {
|
|
120
|
+
sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
let payload
|
|
124
|
+
try {
|
|
125
|
+
payload = JSON.parse(await readBody(req))
|
|
126
|
+
} catch (_e) {
|
|
127
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
const text = (payload && typeof payload.text === 'string') ? payload.text.trim() : ''
|
|
131
|
+
const result = await proofread(ctx, text)
|
|
132
|
+
sendJson(res, 200, result)
|
|
133
|
+
},
|
|
134
|
+
}), 'dsh-voice-input: /dsh-voice-input/proofread route')
|
|
135
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sakka6868/dsh-voice-input",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness Web UI 语音输入插件:发送按钮左侧的麦克风按钮,调用浏览器 Web Speech API(Edge 中即微软语音服务)实时把语音转成文字写入输入框,停止后自动校对(错别字/同音偏差/繁体转简体/标点)。支持 Ctrl+Shift+M 快捷键,零依赖、无需 API Key。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"default": "./lib/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./client": {
|
|
13
|
+
"default": "./lib/client.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"lib",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"dsh": {
|
|
26
|
+
"client": {
|
|
27
|
+
"platform": "web",
|
|
28
|
+
"immediately": false,
|
|
29
|
+
"inject": [
|
|
30
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
37
|
+
"@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.6",
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.6",
|
|
39
|
+
"react": "^18.2.0"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"dsh",
|
|
43
|
+
"deepseek-harness",
|
|
44
|
+
"cordis",
|
|
45
|
+
"plugin",
|
|
46
|
+
"client-plugin",
|
|
47
|
+
"voice",
|
|
48
|
+
"speech-to-text",
|
|
49
|
+
"stt",
|
|
50
|
+
"dictation",
|
|
51
|
+
"web-speech-api"
|
|
52
|
+
],
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|