@xmanrui/dsh-im 4.28.1 → 4.29.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.28.1",
3
+ "version": "4.29.0",
4
4
  "description": "把十二种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect twelve IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -85,6 +85,10 @@
85
85
  {
86
86
  "name": "Zhe (Phil) Yang",
87
87
  "url": "https://github.com/yangzhe1991"
88
+ },
89
+ {
90
+ "name": "Biertolovisk",
91
+ "url": "https://github.com/rosalynthompson32-a11y"
88
92
  }
89
93
  ],
90
94
  "license": "MIT",
@@ -14,6 +14,7 @@ import { normalizeLastMessageError } from "../../last-message-error.js";
14
14
  import { normalizeAccessPolicy } from "../../../../src/channels/shared/access-policy.mjs";
15
15
  import { normalizeContextEnhancementConfig } from "../../../../src/channels/shared/context-enhancement.mjs";
16
16
  import { normalizeFeishuStepPushMode } from "../../../../src/channels/feishu/step-push-mode.mjs";
17
+ import { normalizeFeishuVoiceConfig } from "../../../../src/channels/feishu/voice-config.mjs";
17
18
 
18
19
  export const FEISHU_RPC_CHANNEL = "/feishu";
19
20
 
@@ -38,6 +39,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
38
39
  setGroupTopicReply: "bot.group-topic-reply.set",
39
40
  setStepPush: "bot.step-push.set",
40
41
  setStepPushMode: "bot.step-push-mode.set",
42
+ setVoice: "bot.voice.set",
41
43
  // Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
42
44
  testConnection: "connection.test",
43
45
  disconnect: "connection.disconnect",
@@ -227,6 +229,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
227
229
  groupTopicReply: value.groupTopicReply === true,
228
230
  stepPush: value.stepPush === true,
229
231
  stepPushMode: normalizeFeishuStepPushMode(value.stepPushMode),
232
+ voice: normalizeFeishuVoiceConfig(value.voice),
230
233
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
231
234
  bot: normalizeBot(value.bot),
232
235
  health: normalizeHealth(value.health, connected),
@@ -0,0 +1,115 @@
1
+ import * as React from 'react';
2
+
3
+ import { h } from '../../i18n.js';
4
+
5
+ // 语音交互编辑器:开关 + 凭据引用 + 模型/音色参数。
6
+ // 凭据值由插件凭据库管理,这里只保存 secretRef 名称——密钥本身不进入配置,
7
+ // 也不经本表单传输。开启后:收到语音消息时转写为文字走正常流水线,并用
8
+ // 语音回复同回合答案(需 DashScope qwen3 系列密钥与可用 ffmpeg)。
9
+ export function VoiceEditor({ value = null, disabled = false, onSave }) {
10
+ const [enabled, setEnabled] = React.useState(value != null);
11
+ const [secretRef, setSecretRef] = React.useState(value?.secretRef ?? 'DASHSCOPE_API_KEY');
12
+ const [asrModel, setAsrModel] = React.useState(value?.asrModel ?? 'qwen3-asr-flash');
13
+ const [ttsModel, setTtsModel] = React.useState(value?.ttsModel ?? 'qwen3-tts-flash');
14
+ const [ttsVoice, setTtsVoice] = React.useState(value?.ttsVoice ?? 'Momo');
15
+ const [saving, setSaving] = React.useState(false);
16
+ const [error, setError] = React.useState(null);
17
+ const titleId = React.useId();
18
+ const helpId = `${titleId}-help`;
19
+
20
+ React.useEffect(() => {
21
+ setEnabled(value != null);
22
+ if (value) {
23
+ setSecretRef(value.secretRef ?? 'DASHSCOPE_API_KEY');
24
+ setAsrModel(value.asrModel ?? 'qwen3-asr-flash');
25
+ setTtsModel(value.ttsModel ?? 'qwen3-tts-flash');
26
+ setTtsVoice(value.ttsVoice ?? 'Momo');
27
+ }
28
+ }, [value]);
29
+
30
+ const persist = async (payload) => {
31
+ if (saving || disabled) return;
32
+ setSaving(true);
33
+ setError(null);
34
+ try {
35
+ await onSave?.(payload);
36
+ } catch (cause) {
37
+ setError(cause?.message ?? '语音设置保存失败,请重试。');
38
+ } finally {
39
+ setSaving(false);
40
+ }
41
+ };
42
+
43
+ const save = () => persist(enabled ? { enabled: true, secretRef, asrModel, ttsModel, ttsVoice } : null);
44
+
45
+ // 切到“关闭”立即持久化 null:关闭态没有保存按钮,若不在这里落盘,
46
+ // 后台会一直保持原配置,用户以为语音已停而实际仍在处理语音。
47
+ const toggle = (next) => {
48
+ setEnabled(next);
49
+ if (!next) void persist(null);
50
+ };
51
+
52
+ const input = (label, state, setState, placeholder) => h('label', { className: 'dim-feishuVoiceField' },
53
+ h('span', { className: 'dim-feishuVoiceLabel' }, label),
54
+ h('input', {
55
+ type: 'text',
56
+ className: 'dim-feishuVoiceInput',
57
+ value: state,
58
+ placeholder,
59
+ disabled: disabled || saving,
60
+ onChange: (event) => setState(event.target.value),
61
+ }),
62
+ );
63
+
64
+ return h('section', {
65
+ className: 'dim-feishuGroupControl',
66
+ "aria-labelledby": titleId,
67
+ },
68
+ h('div', { className: 'dim-feishuGroupControlHeader' },
69
+ h('div', { className: 'dim-presetTitle' },
70
+ h('h3', { id: titleId }, '语音交互'),
71
+ h('span', { className: 'dim-presetHelp' },
72
+ h('button', {
73
+ type: 'button',
74
+ className: 'dim-presetHelpButton',
75
+ "aria-label": '查看语音交互说明',
76
+ "aria-describedby": helpId,
77
+ }, h('span', { "aria-hidden": 'true' }, '?')),
78
+ h('span', {
79
+ id: helpId,
80
+ className: 'dim-presetTooltip',
81
+ role: 'tooltip',
82
+ }, '收到语音消息时转写为文字并按文字流水线处理,答案同时以语音回复;转写失败时降级为仅支持文字/图片/文件的提示。凭据值在插件凭据库里管理,这里只引用环境变量名。'))),
83
+ saving
84
+ ? h('span', { className: 'dim-feishuGroupControlStatus', role: 'status' }, '保存中…')
85
+ : null),
86
+ h('select', {
87
+ className: 'dim-feishuGroupSelect',
88
+ value: enabled ? 'on' : 'off',
89
+ disabled: disabled || saving,
90
+ "aria-label": '语音交互开关',
91
+ onChange: (event) => toggle(event.target.value === 'on'),
92
+ },
93
+ h('option', { value: 'off' }, '关闭'),
94
+ h('option', { value: 'on' }, '开启(语音提问与语音回复)')),
95
+ enabled
96
+ ? h('div', { className: 'dim-feishuVoiceForm' },
97
+ input('凭据引用(环境变量名)', secretRef, setSecretRef, 'DASHSCOPE_API_KEY'),
98
+ input('转写模型(ASR)', asrModel, setAsrModel, 'qwen3-asr-flash'),
99
+ input('合成模型(TTS)', ttsModel, setTtsModel, 'qwen3-tts-flash'),
100
+ input('回复音色', ttsVoice, setTtsVoice, 'Momo'),
101
+ h('p', { className: 'dim-feishuGroupHelp' },
102
+ '需要 DashScope 密钥(凭据库中以"凭据引用"为名的环境变量)与可用 ffmpeg;音色支持 Cherry/Serena/Bella/Chelsie/Momo/Katerina 等 qwen3-tts-flash 官方音色。'),
103
+ h('div', { className: 'dim-feishuVoiceActions' },
104
+ h('button', {
105
+ type: 'button',
106
+ className: 'dim-feishuVoiceSave',
107
+ onClick: () => void save(),
108
+ disabled: disabled || saving || !secretRef.trim(),
109
+ }, '保存语音设置')))
110
+ : h('p', { className: 'dim-feishuGroupHelp' }, '默认关闭;开启后机器人可听懂语音消息并以语音回复。'),
111
+ error ? h('p', {
112
+ className: 'dim-feishuGroupError',
113
+ role: 'alert',
114
+ }, error) : null);
115
+ }
@@ -0,0 +1,71 @@
1
+ import * as React from 'react';
2
+
3
+ import { h } from '../../i18n.js';
4
+ import { FEISHU_ENDPOINTS, normalizeBotsSnapshot, unwrapRpcResult } from './api.js';
5
+ import { VoiceEditor } from './voice-editor.js';
6
+
7
+ export function FeishuVoiceSettingsPage({ account, rpcCall }) {
8
+ const [voice, setVoice] = React.useState(null);
9
+ const [phase, setPhase] = React.useState('loading');
10
+ const [error, setError] = React.useState(null);
11
+ const mounted = React.useRef(true);
12
+
13
+ const invoke = React.useCallback(async (endpoint, payload, signal) => {
14
+ if (typeof rpcCall !== 'function') throw new Error('飞书语音设置暂不可用。');
15
+ return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
16
+ }, [rpcCall]);
17
+
18
+ const applySnapshot = React.useCallback((value) => {
19
+ const bot = normalizeBotsSnapshot(value).bots.find((entry) => entry.botId === account.botId);
20
+ if (!bot) throw new Error('未找到当前飞书机器人,请返回列表后重试。');
21
+ if (mounted.current) setVoice(bot.voice);
22
+ }, [account.botId]);
23
+
24
+ const loadSettings = React.useCallback(async (signal) => {
25
+ setPhase('loading');
26
+ setError(null);
27
+ try {
28
+ const value = await invoke(FEISHU_ENDPOINTS.status, {}, signal);
29
+ if (signal?.aborted || !mounted.current) return;
30
+ applySnapshot(value);
31
+ setPhase('ready');
32
+ } catch (cause) {
33
+ if (signal?.aborted || !mounted.current) return;
34
+ setError(cause?.message ?? '飞书语音设置暂不可用。');
35
+ setPhase('error');
36
+ }
37
+ }, [applySnapshot, invoke]);
38
+
39
+ React.useEffect(() => {
40
+ mounted.current = true;
41
+ const controller = new AbortController();
42
+ void loadSettings(controller.signal);
43
+ return () => {
44
+ mounted.current = false;
45
+ controller.abort();
46
+ };
47
+ }, [loadSettings]);
48
+
49
+ const saveVoice = async (settings) => {
50
+ const value = await invoke(FEISHU_ENDPOINTS.setVoice, {
51
+ botId: account.botId,
52
+ voice: settings === null ? null : { ...voice, ...settings },
53
+ });
54
+ if (mounted.current) applySnapshot(value);
55
+ };
56
+
57
+ if (phase === 'loading') {
58
+ return h('div', { className: 'dim-deliveryState', role: 'status', 'aria-busy': 'true' },
59
+ '正在读取语音设置…');
60
+ }
61
+ if (phase === 'error') {
62
+ return h('div', { className: 'dim-deliveryState', role: 'alert' },
63
+ h('p', null, error),
64
+ h('button', {
65
+ type: 'button',
66
+ className: 'dim-deliveryButton',
67
+ onClick: () => void loadSettings(),
68
+ }, '重新读取'));
69
+ }
70
+ return h(VoiceEditor, { value: voice, onSave: saveVoice });
71
+ }
@@ -2,6 +2,7 @@ import * as React from 'react';
2
2
 
3
3
  import { AccessPolicySettingsPage } from './access-policy-settings.js';
4
4
  import { FeishuGroupSettingsPage } from './channels/feishu/group-settings.js';
5
+ import { FeishuVoiceSettingsPage } from './channels/feishu/voice-settings.js';
5
6
  import { h, isEnglish, localizeText } from './i18n.js';
6
7
 
7
8
  export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
@@ -29,6 +30,7 @@ export const BOT_SETTINGS_TABS = Object.freeze([
29
30
  export const FEISHU_BOT_SETTINGS_TABS = Object.freeze([
30
31
  ...BOT_SETTINGS_TABS,
31
32
  Object.freeze({ id: 'group', label: '群聊' }),
33
+ Object.freeze({ id: 'voice', label: '语音交互' }),
32
34
  ]);
33
35
 
34
36
  export function botSettingsTabsForChannel(channel) {
@@ -792,6 +794,12 @@ export function DeliveryTargetSettingsPage({
792
794
  account,
793
795
  rpcCall: accessRpcCall,
794
796
  })
797
+ : activeTab.id === 'voice' && channel === 'feishu'
798
+ ? h(FeishuVoiceSettingsPage, {
799
+ key: account.botId,
800
+ account,
801
+ rpcCall: accessRpcCall,
802
+ })
795
803
  : h(React.Fragment, null,
796
804
  h('section', { className: 'dim-deliveryIdentity', 'aria-labelledby': 'dim-delivery-bot-title' },
797
805
  h('div', { className: 'dim-deliveryIdentityHeading' },
@@ -282,6 +282,22 @@ const EN = Object.freeze({
282
282
  '推荐长任务使用:过程与最终答案都在同一张卡片里实时更新,不刷屏': 'Recommended for long tasks: the process and the final answer update live in one card without flooding the chat',
283
283
  '每一步都单独发一条消息(含工具调用和过程说明);注意长任务会连续发送较多消息': 'Every step is sent as its own message (including tool calls and notes); long tasks may send many messages in a row',
284
284
  '分步直推设置保存失败,请重试。': 'Could not save the step push setting. Try again.',
285
+ '语音交互': 'Voice',
286
+ '正在读取语音设置…': 'Loading voice settings…',
287
+ '飞书语音设置暂不可用。': 'Feishu voice settings are unavailable.',
288
+ '未找到当前飞书机器人,请返回列表后重试。': 'This Feishu bot was not found. Return to the bot list and try again.',
289
+ '查看语音交互说明': 'View voice help',
290
+ '语音交互开关': 'Voice on/off',
291
+ '开启(语音提问与语音回复)': 'On (voice questions and voice replies)',
292
+ '收到语音消息时转写为文字并按文字流水线处理,答案同时以语音回复;转写失败时降级为仅支持文字/图片/文件的提示。凭据值在插件凭据库里管理,这里只引用环境变量名。': 'Voice messages are transcribed to text and processed by the normal text pipeline; the answer is also sent as a voice reply. If transcription fails, the bot falls back to the text/image/file-only notice. The credential value lives in the plugin credential store; only the environment variable name is referenced here.',
293
+ '凭据引用(环境变量名)': 'Credential reference (environment variable name)',
294
+ '转写模型(ASR)': 'Transcription model (ASR)',
295
+ '合成模型(TTS)': 'Synthesis model (TTS)',
296
+ '回复音色': 'Reply voice',
297
+ '需要 DashScope 密钥(凭据库中以"凭据引用"为名的环境变量)与可用 ffmpeg;音色支持 Cherry/Serena/Bella/Chelsie/Momo/Katerina 等 qwen3-tts-flash 官方音色。': 'Requires a DashScope API key (the credential-store environment variable named by the credential reference above) and a working ffmpeg. Supported voices include the official qwen3-tts-flash voices Cherry/Serena/Bella/Chelsie/Momo/Katerina.',
298
+ '保存语音设置': 'Save voice settings',
299
+ '默认关闭;开启后机器人可听懂语音消息并以语音回复。': 'Off by default. When enabled, the bot understands voice messages and replies by voice.',
300
+ '语音设置保存失败,请重试。': 'Could not save the voice settings. Try again.',
285
301
  'zcode过程卡模式': 'ZCode process-card mode',
286
302
  '过程说明、工具摘要与最终答案都在同一张卡片中原地刷新':
287
303
  'Process notes, the tool summary, and the final answer refresh in place inside one card',
@@ -479,6 +479,17 @@ const CSS = String.raw`
479
479
  .dim-feishuGroupSelect:focus-visible { outline: none; border-color: var(--dsw-alias-state-business-primary, #3370ff); box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 15%, transparent); }
480
480
  .dim-feishuGroupSelect:disabled { cursor: not-allowed; opacity: .55; }
481
481
  .dim-feishuGroupHelp { margin: -2px 0 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: 19px; }
482
+ .dim-feishuVoiceForm { min-width: 0; display: grid; gap: 10px; }
483
+ .dim-feishuVoiceField { min-width: 0; display: grid; gap: 5px; }
484
+ .dim-feishuVoiceLabel { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
485
+ .dim-feishuVoiceInput { min-width: 0; width: 100%; height: 36px; padding: 0 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; line-height: 36px; transition: border-color .15s ease, box-shadow .15s ease; }
486
+ .dim-feishuVoiceInput:hover:not(:disabled) { border-color: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 42%, var(--dsw-alias-border-l2, #dfe1e5)); }
487
+ .dim-feishuVoiceInput:focus-visible { outline: none; border-color: var(--dsw-alias-state-business-primary, #3370ff); box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 15%, transparent); }
488
+ .dim-feishuVoiceInput:disabled { cursor: not-allowed; opacity: .55; }
489
+ .dim-feishuVoiceActions { display: flex; justify-content: flex-start; }
490
+ .dim-feishuVoiceSave { height: 34px; padding: 0 15px; border: 1px solid var(--dsw-alias-state-business-primary, #3370ff); border-radius: 8px; color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; line-height: 32px; cursor: pointer; transition: border-color .15s ease, color .15s ease, background .15s ease; }
491
+ .dim-feishuVoiceSave:hover:not(:disabled) { color: #0f5fce; border-color: #0f5fce; background: color-mix(in srgb, #3370ff 6%, var(--dsw-alias-bg-layer-1, #fff)); }
492
+ .dim-feishuVoiceSave:disabled { cursor: not-allowed; opacity: .55; }
482
493
  .dim-feishuGroupPermissionAction { display: flex; justify-content: flex-start; }
483
494
  .dim-feishuGroupPermissionAction .dim-deliveryButton { color: var(--dsw-alias-state-business-primary, #3370ff); border-color: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 30%, var(--dsw-alias-border-l2, #dfe1e5)); }
484
495
  .dim-feishuGroupError, .dim-feishuGroupRefreshError { margin: 0; padding: 9px 11px; border-radius: 8px; font-size: 12px; line-height: 18px; }
@@ -234,6 +234,16 @@ export async function createProductionController(ctx, config = {}, internals = {
234
234
  const workspaceScope = createBotWorkspaceScope(harness, {
235
235
  botId: id, workspaces, state, agentPresetCatalog,
236
236
  });
237
+ // 语音是可选渠道能力:凭据读取失败时静默禁用,不阻断机器人连接。
238
+ let voiceSource = null;
239
+ if (botConfig.voice) {
240
+ try {
241
+ const resolved = await ctx.credentials.resolve(botConfig.voice.secretRef);
242
+ if (resolved?.value) voiceSource = { config: botConfig.voice, secret: resolved.value };
243
+ } catch (error) {
244
+ logger.warn?.('[dsh-feishu-voice] 语音凭据读取失败,语音已禁用:', error?.message ?? String(error));
245
+ }
246
+ }
237
247
  return new Runtime({
238
248
  lark,
239
249
  botId: id,
@@ -246,6 +256,7 @@ export async function createProductionController(ctx, config = {}, internals = {
246
256
  groupTopicReply: botConfig.groupTopicReply,
247
257
  stepPush: botConfig.stepPush,
248
258
  stepPushMode: botConfig.stepPushMode,
259
+ voice: voiceSource,
249
260
  sessionSyncTargetsFor: sessionSyncTargetsFor,
250
261
  ownerOpenIds: botConfig.ownerOpenIds ?? [botConfig.ownerOpenId],
251
262
  harness: workspaceScope.harness,
@@ -30,6 +30,7 @@ import {
30
30
  isFeishuStepPushMode,
31
31
  normalizeFeishuStepPushMode,
32
32
  } from '../../../../src/channels/feishu/step-push-mode.mjs';
33
+ import { normalizeFeishuVoiceConfig } from '../../../../src/channels/feishu/voice-config.mjs';
33
34
  import {
34
35
  FEISHU_ENDPOINTS as FEISHU_CLIENT_ENDPOINTS,
35
36
  FEISHU_RPC_CHANNEL,
@@ -307,6 +308,7 @@ function publicBotEntry(entry) {
307
308
  groupTopicReply: source.groupTopicReply === true,
308
309
  stepPush: source.stepPush === true,
309
310
  stepPushMode: normalizeFeishuStepPushMode(source.stepPushMode),
311
+ voice: normalizeFeishuVoiceConfig(source.voice),
310
312
  groupMessagePermissionGranted: source.groupMessagePermissionGranted === true,
311
313
  bot: publicBot(source.bot),
312
314
  health: publicHealth(source, connected),
@@ -490,6 +492,17 @@ function validPayload(endpoint, payload) {
490
492
  ? null
491
493
  : '请选择分步直推的呈现方式。';
492
494
  }
495
+ if (endpoint === FEISHU_ENDPOINTS.setVoice) {
496
+ const voice = payload?.voice;
497
+ const validVoice = voice === null
498
+ || (typeof voice === 'object' && !Array.isArray(voice)
499
+ && (voice.enabled === false || normalizeFeishuVoiceConfig(voice) !== null));
500
+ return hasOnlyKeys(payload, new Set(['botId', 'voice']))
501
+ && safeOpaqueId(payload.botId)
502
+ && validVoice
503
+ ? null
504
+ : '语音配置无效。';
505
+ }
493
506
  return 'Unknown Feishu endpoint.';
494
507
  }
495
508
 
@@ -797,6 +810,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
797
810
  await controller.updateStepPushMode(payload.botId, payload.stepPushMode),
798
811
  { encodeQr: cachedEncodeQr },
799
812
  );
813
+ } else if (endpoint === FEISHU_ENDPOINTS.setVoice) {
814
+ if (typeof controller.updateVoice !== 'function') {
815
+ throw new Error('Voice update is unavailable');
816
+ }
817
+ value = await toPublicFeishuStatus(
818
+ await controller.updateVoice(payload.botId, payload.voice),
819
+ { encodeQr: cachedEncodeQr },
820
+ );
800
821
  } else {
801
822
  if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable');
802
823
  value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr });