@xmanrui/dsh-im 2.1.0 → 2.2.1

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": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -99,7 +99,7 @@ export function WhatsappAccessSettings({ account, busy = false, onSave }) {
99
99
  h('span', null, '响应自聊和白名单联系人的私聊,忽略群聊。')),
100
100
  h('span', { className: 'dwa-accessTooltipItem' },
101
101
  h('strong', null, '开放响应模式'),
102
- h('span', null, '响应所有私聊,以及群聊中的提及或回复。')))))),
102
+ h('span', null, '响应所有私聊、已绑定账号自己发出的群聊消息,以及其他群成员的提及或回复。')))))),
103
103
  h('label', { className: 'dwa-accessField' },
104
104
  h('span', null, '模式'),
105
105
  h('select', {
@@ -55,6 +55,10 @@ const EN = Object.freeze({
55
55
  '本机暂时无法访问 AI Office。': 'AI Office cannot currently be reached from this machine.',
56
56
  'AI Office 连接已中断。': 'The AI Office connection was interrupted.',
57
57
  '帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
58
+ '请改用 localhost 重新打开': 'Reopen with localhost',
59
+ '页面会在当前端口重新打开,机器人配置不会改变。': 'The page will reopen on the current port. Your bot configuration will not change.',
60
+ '使用 localhost 重新打开': 'Reopen with localhost',
61
+ '当前地址与浏览器的本机请求校验不兼容。请使用上方按钮改用 localhost 重新打开。': 'This address is incompatible with the browser’s local-request checks. Use the button above to reopen with localhost.',
58
62
  '微信': 'WeChat',
59
63
  '飞书': 'Feishu',
60
64
  '钉钉': 'DingTalk',
@@ -369,7 +373,7 @@ const EN = Object.freeze({
369
373
  '已生效:': 'Active: ',
370
374
  '只响应已绑定 WhatsApp 账号的自聊消息。': 'Only respond to self-chat messages from the linked WhatsApp account.',
371
375
  '响应自聊和白名单联系人的私聊,忽略群聊。': 'Respond to self-chat and allowlisted direct messages; ignore group messages.',
372
- '响应所有私聊,以及群聊中的提及或回复。': 'Respond to all direct messages and to group mentions or replies.',
376
+ '响应所有私聊、已绑定账号自己发出的群聊消息,以及其他群成员的提及或回复。': 'Respond to all direct messages, group messages sent by the linked account, and mentions or replies from other group members.',
373
377
  '允许私聊的 WhatsApp 电话号码': 'WhatsApp phone numbers allowed to send direct messages',
374
378
  '每行一个含国家或地区代码的号码': 'One number with country or region code per line',
375
379
  '可以包含开头的 +,保存时会自动移除。': 'A leading + is allowed and removed when saved.',
@@ -42,6 +42,10 @@ import { WHATSAPP_RPC_CHANNEL } from './channels/whatsapp/api.js';
42
42
  import { WhatsappSettingsTab } from './channels/whatsapp/index.js';
43
43
  import { installWhatsappStyles } from './channels/whatsapp/styles.js';
44
44
  import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
45
+ import {
46
+ createLoopbackAwareRpcCalls,
47
+ replacePageLocation,
48
+ } from './loopback-recovery.js';
45
49
  import { installImStyles } from './styles.js';
46
50
  import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
47
51
 
@@ -122,6 +126,22 @@ function ChannelLogo({ channel }) {
122
126
  return h(OfficeLogo);
123
127
  }
124
128
 
129
+ export function LoopbackRecoveryNotice({ recovery, onNavigate = replacePageLocation }) {
130
+ return h('div', {
131
+ className: 'dim-loopbackRecovery',
132
+ role: 'alert',
133
+ },
134
+ h('div', { className: 'dim-loopbackRecoveryCopy' },
135
+ h('strong', null, '请改用 localhost 重新打开'),
136
+ h('p', null, '页面会在当前端口重新打开,机器人配置不会改变。'),
137
+ h('code', null, recovery.origin)),
138
+ h('button', {
139
+ type: 'button',
140
+ className: 'dim-loopbackRecoveryAction',
141
+ onClick: () => onNavigate(recovery.url),
142
+ }, '使用 localhost 重新打开'));
143
+ }
144
+
125
145
  export function IMSettingsTab({
126
146
  dingtalkRpcCall,
127
147
  discordRpcCall,
@@ -134,10 +154,44 @@ export function IMSettingsTab({
134
154
  whatsappRpcCall,
135
155
  officeRpcCall,
136
156
  workspaceDirectoryPicker,
157
+ browserLocation = globalThis.location,
158
+ navigateToRecoveryUrl = replacePageLocation,
137
159
  }) {
138
160
  const [selected, setSelected] = React.useState('weixin');
161
+ const [loopbackRecovery, setLoopbackRecovery] = React.useState(null);
139
162
  const githubTooltipId = React.useId();
140
163
  const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
164
+ const reportLoopbackRecovery = React.useCallback((recovery) => {
165
+ setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
166
+ }, []);
167
+ const rpcCalls = React.useMemo(() => createLoopbackAwareRpcCalls({
168
+ dingtalkRpcCall,
169
+ discordRpcCall,
170
+ feishuRpcCall,
171
+ qqRpcCall,
172
+ slackRpcCall,
173
+ telegramRpcCall,
174
+ wecomRpcCall,
175
+ weixinRpcCall,
176
+ whatsappRpcCall,
177
+ officeRpcCall,
178
+ }, {
179
+ location: browserLocation,
180
+ onRecovery: reportLoopbackRecovery,
181
+ }), [
182
+ browserLocation,
183
+ dingtalkRpcCall,
184
+ discordRpcCall,
185
+ feishuRpcCall,
186
+ officeRpcCall,
187
+ qqRpcCall,
188
+ reportLoopbackRecovery,
189
+ slackRpcCall,
190
+ telegramRpcCall,
191
+ wecomRpcCall,
192
+ weixinRpcCall,
193
+ whatsappRpcCall,
194
+ ]);
141
195
  return h(WorkspaceDirectoryPickerContext.Provider, { value: workspaceDirectoryPicker },
142
196
  h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
143
197
  h('header', { className: 'dim-title' },
@@ -184,25 +238,32 @@ export function IMSettingsTab({
184
238
  role: 'tabpanel',
185
239
  id: `dim-panel-${active.id}`,
186
240
  'aria-labelledby': `dim-tab-${active.id}`,
187
- }, active.id === 'weixin'
188
- ? h(WeixinSettingsTab, { rpcCall: weixinRpcCall })
241
+ },
242
+ loopbackRecovery
243
+ ? h(LoopbackRecoveryNotice, {
244
+ recovery: loopbackRecovery,
245
+ onNavigate: navigateToRecoveryUrl,
246
+ })
247
+ : null,
248
+ active.id === 'weixin'
249
+ ? h(WeixinSettingsTab, { rpcCall: rpcCalls.weixinRpcCall })
189
250
  : active.id === 'feishu'
190
- ? h(FeishuSettingsTab, { rpcCall: feishuRpcCall })
251
+ ? h(FeishuSettingsTab, { rpcCall: rpcCalls.feishuRpcCall })
191
252
  : active.id === 'dingtalk'
192
- ? h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall })
253
+ ? h(DingtalkSettingsTab, { rpcCall: rpcCalls.dingtalkRpcCall })
193
254
  : active.id === 'wecom'
194
- ? h(WecomSettingsTab, { rpcCall: wecomRpcCall })
255
+ ? h(WecomSettingsTab, { rpcCall: rpcCalls.wecomRpcCall })
195
256
  : active.id === 'qq'
196
- ? h(QqSettingsTab, { rpcCall: qqRpcCall })
257
+ ? h(QqSettingsTab, { rpcCall: rpcCalls.qqRpcCall })
197
258
  : active.id === 'slack'
198
- ? h(SlackSettingsTab, { rpcCall: slackRpcCall })
259
+ ? h(SlackSettingsTab, { rpcCall: rpcCalls.slackRpcCall })
199
260
  : active.id === 'telegram'
200
- ? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
261
+ ? h(TelegramSettingsTab, { rpcCall: rpcCalls.telegramRpcCall })
201
262
  : active.id === 'discord'
202
- ? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
263
+ ? h(DiscordSettingsTab, { rpcCall: rpcCalls.discordRpcCall })
203
264
  : active.id === 'whatsapp'
204
- ? h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })
205
- : h(OfficeSettingsTab, { rpcCall: officeRpcCall })),
265
+ ? h(WhatsappSettingsTab, { rpcCall: rpcCalls.whatsappRpcCall })
266
+ : h(OfficeSettingsTab, { rpcCall: rpcCalls.officeRpcCall })),
206
267
  ),
207
268
  ));
208
269
  }
@@ -0,0 +1,75 @@
1
+ const TRANSPORT_FORBIDDEN = /^transport failure for \/[A-Za-z0-9._~-]+\/[A-Za-z0-9_$./~-]+: HTTP 403$/;
2
+
3
+ export const LOOPBACK_RECOVERY_ERROR_CODE = 'loopback-recovery-required';
4
+ export const LOOPBACK_RECOVERY_ERROR_MESSAGE =
5
+ '当前地址与浏览器的本机请求校验不兼容。请使用上方按钮改用 localhost 重新打开。';
6
+
7
+ function isIpv4Loopback(hostname) {
8
+ const parts = hostname.split('.');
9
+ return parts.length === 4
10
+ && parts[0] === '127'
11
+ && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
12
+ }
13
+
14
+ /**
15
+ * Return a safe localhost navigation target for the known loopback transport failure.
16
+ */
17
+ export function createLoopbackRecovery(error, location) {
18
+ if (!TRANSPORT_FORBIDDEN.test(error?.message ?? '')) return null;
19
+ if (typeof location?.href !== 'string') return null;
20
+
21
+ try {
22
+ const current = new URL(location.href);
23
+ if (current.protocol !== 'http:' || !isIpv4Loopback(current.hostname)) return null;
24
+ current.hostname = 'localhost';
25
+ return Object.freeze({
26
+ url: current.href,
27
+ origin: current.origin,
28
+ });
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Decorate one IM RPC caller with a narrowly scoped localhost recovery signal.
36
+ */
37
+ export function createLoopbackAwareRpcCall(rpcCall, {
38
+ location,
39
+ onRecovery,
40
+ } = {}) {
41
+ if (typeof rpcCall !== 'function') throw new TypeError('rpcCall must be a function');
42
+ return async (...args) => {
43
+ try {
44
+ return await rpcCall(...args);
45
+ } catch (error) {
46
+ const recovery = createLoopbackRecovery(error, location);
47
+ if (!recovery) throw error;
48
+ onRecovery?.(recovery);
49
+ const presented = new Error(LOOPBACK_RECOVERY_ERROR_MESSAGE);
50
+ presented.code = LOOPBACK_RECOVERY_ERROR_CODE;
51
+ presented.cause = error;
52
+ presented.recoveryUrl = recovery.url;
53
+ throw presented;
54
+ }
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Apply the same recovery behavior to every RPC caller in the combined settings page.
60
+ */
61
+ export function createLoopbackAwareRpcCalls(rpcCalls, options) {
62
+ return Object.freeze(Object.fromEntries(
63
+ Object.entries(rpcCalls).map(([name, rpcCall]) => [
64
+ name,
65
+ typeof rpcCall === 'function'
66
+ ? createLoopbackAwareRpcCall(rpcCall, options)
67
+ : rpcCall,
68
+ ]),
69
+ ));
70
+ }
71
+
72
+ /** Navigate without leaving the known-broken loopback address in browser history. */
73
+ export function replacePageLocation(url, location = globalThis.location) {
74
+ location?.replace?.(url);
75
+ }
@@ -57,6 +57,14 @@ const CSS = String.raw`
57
57
  .dim-channelNote { overflow: hidden; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; line-height: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
58
58
  .dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-border-l1, #eef0f3); }
59
59
  .dim-panel { min-width: 0; container-type: inline-size; }
60
+ .dim-loopbackRecovery { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 14px; padding: 14px 16px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 30%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 12px; color: var(--dsw-alias-label-primary, #1f2329); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 8%, var(--dsw-alias-bg-layer-1, #fff)); }
61
+ .dim-loopbackRecoveryCopy { min-width: 0; }
62
+ .dim-loopbackRecoveryCopy strong { display: block; font-size: 14px; line-height: 20px; font-weight: 650; }
63
+ .dim-loopbackRecoveryCopy p { margin: 3px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
64
+ .dim-loopbackRecoveryCopy code { display: block; overflow: hidden; margin-top: 5px; color: var(--dsw-alias-label-secondary, #646a73); font: 11px/16px ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
65
+ .dim-loopbackRecoveryAction { flex: none; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; padding: 0 12px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; cursor: pointer; }
66
+ .dim-loopbackRecoveryAction:hover { border-color: #0958d9; background: #0958d9; }
67
+ .dim-loopbackRecoveryAction:focus-visible { outline: 2px solid color-mix(in srgb, #1677ff 62%, white); outline-offset: 2px; }
60
68
  .dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page, .dim-panel .dsl-page, .dim-panel .dwa-page { width: 100%; max-width: none; padding: 0 0 24px; }
61
69
  .dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; }
62
70
  .dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; justify-content: stretch; gap: 8px; }
@@ -266,6 +274,8 @@ const CSS = String.raw`
266
274
  .dim-title p { white-space: normal; }
267
275
  .dim-githubTooltip { right: auto; left: 0; }
268
276
  .dim-rail { grid-template-columns: minmax(0, 1fr); }
277
+ .dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
278
+ .dim-loopbackRecoveryAction { width: 100%; }
269
279
  .dim-directoryPickerBackdrop { padding: 10px; }
270
280
  .dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
271
281
  .dim-directoryPickerHeader { padding: 18px 17px 14px; }
@@ -23,6 +23,11 @@ const ENDPOINTS = Object.freeze(Object.values(TOKEN_BOT_ENDPOINTS));
23
23
  const FORBIDDEN_PUBLIC_KEYS = new Set([
24
24
  'token', 'botToken', 'tokenRef', 'platformId', 'secret', 'secretRef',
25
25
  ]);
26
+ const TELEGRAM_NETWORK_ERRORS = new Set([
27
+ 'telegram-transport-error',
28
+ 'telegram-timeout',
29
+ 'telegram-response-invalid',
30
+ ]);
26
31
 
27
32
  function isRecord(value) {
28
33
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -88,6 +93,12 @@ function operationError(channel, error) {
88
93
  if (error?.code === 'telegram-401' || error?.code === 'discord-401') {
89
94
  return { code: 'invalid-token', message: `${channel} Bot Token 无效,请重新填写。` };
90
95
  }
96
+ if (channel === 'Telegram' && TELEGRAM_NETWORK_ERRORS.has(error?.code)) {
97
+ return {
98
+ code: 'telegram-network-error',
99
+ message: '无法访问 Telegram Bot API。请检查网络或代理设置;Node.js 22.21+ 可设置 NODE_USE_ENV_PROXY=1,并配置 HTTPS_PROXY、HTTP_PROXY 和 NO_PROXY,然后重启 dsh web。',
100
+ };
101
+ }
91
102
  if (error?.code === 'discord-intents') {
92
103
  return { code: 'discord-intents', message: error.message };
93
104
  }
@@ -5,7 +5,10 @@ import { join, resolve } from 'node:path';
5
5
  import { WeixinConfigStore } from '../../../../src/channels/weixin/config-store.mjs';
6
6
  import { HarnessClient } from '../../../../src/channels/weixin/harness-client.mjs';
7
7
  import { WeixinStateStore } from '../../../../src/channels/weixin/state-store.mjs';
8
- import { createWeixinApi } from '../../../../src/channels/weixin/weixin-api.mjs';
8
+ import {
9
+ createWeixinApi,
10
+ DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
11
+ } from '../../../../src/channels/weixin/weixin-api.mjs';
9
12
  import { WeixinController } from '../../../../src/channels/weixin/weixin-controller.mjs';
10
13
  import { WeixinRuntime } from '../../../../src/channels/weixin/weixin-runtime.mjs';
11
14
  import {
@@ -113,7 +116,7 @@ export async function createProductionController(ctx, config = {}, internals = {
113
116
  harness: workspaceScope.harness,
114
117
  state: workspaceScope.state,
115
118
  replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
116
- maxMessageChars: config.maxMessageChars ?? 4_000,
119
+ maxMessageChars: config.maxMessageChars ?? DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
117
120
  logger: {
118
121
  error: (...args) => logger.error?.(`[${botId}]`, ...args),
119
122
  warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
@@ -57,8 +57,10 @@ const HELP_TEXT_LINES = [
57
57
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
58
58
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
59
59
  '/models 按序号列出所有可用模型',
60
- '/model [序号或完整模型ID] 查看或切换当前会话模型',
61
- '示例:先发 /models,再发 /model 2',
60
+ '/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
61
+ '/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
62
+ '/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
63
+ '示例:先发 /models,再发 /model 2 [推理等级ID]',
62
64
  '/presetlist 按序号列出可用 Agent Preset',
63
65
  '/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
64
66
  '纯数字 ID:/preset id:<ID>',
@@ -509,7 +509,9 @@ export function menuHelpText() {
509
509
  '纯数字 ID:/preset id:<ID>',
510
510
  '/preset --default 跟随 Host 默认',
511
511
  '/models 列出模型',
512
- '/model 2 按序号切换模型',
512
+ '/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
513
+ '/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
514
+ '/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
513
515
  '',
514
516
  '🎮 任务控制',
515
517
  '/stop 停止当前任务',
@@ -559,7 +561,9 @@ const HELP_TEXT_COMMANDS = [
559
561
  '`/preset [序号/ID]` — 切换预设',
560
562
  '`/preset --default` — 跟随默认',
561
563
  '`/models` — 列出模型',
562
- '`/model 2` — 切换模型',
564
+ '`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级',
565
+ '`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级',
566
+ '`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型',
563
567
  '`/repair` — 修复卡片按钮',
564
568
  ].join('\n');
565
569
 
@@ -70,8 +70,10 @@ function helpText() {
70
70
  t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
71
71
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
72
72
  t('/models 按序号列出所有可用模型'),
73
- t('/model [序号或完整模型ID] 查看或切换当前会话模型'),
74
- t('示例:先发 /models,再发 /model 2'),
73
+ t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
74
+ t('/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级'),
75
+ t('/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型'),
76
+ t('示例:先发 /models,再发 /model 2 [推理等级ID]'),
75
77
  t('/presetlist 按序号列出可用 Agent Preset'),
76
78
  t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
77
79
  t('纯数字 ID:/preset id:<ID>'),
@@ -104,7 +104,26 @@ function validModelSelection(value) {
104
104
  && Boolean(value.provider)
105
105
  && typeof value.model === 'string'
106
106
  && Boolean(value.model)
107
- && (value.reasoningEffort === undefined || typeof value.reasoningEffort === 'string');
107
+ && (value.reasoningEffort === undefined
108
+ || (typeof value.reasoningEffort === 'string' && Boolean(value.reasoningEffort)));
109
+ }
110
+
111
+ function validModelReasoning(value) {
112
+ return value !== null
113
+ && typeof value === 'object'
114
+ && Array.isArray(value.efforts)
115
+ && value.efforts.length > 0
116
+ && value.efforts.every((effort) => (
117
+ effort !== null
118
+ && typeof effort === 'object'
119
+ && typeof effort.id === 'string'
120
+ && Boolean(effort.id)
121
+ && typeof effort.name === 'string'
122
+ && Boolean(effort.name)
123
+ && (effort.description === undefined || typeof effort.description === 'string')
124
+ ))
125
+ && (value.defaultEffort === undefined
126
+ || (typeof value.defaultEffort === 'string' && Boolean(value.defaultEffort)));
108
127
  }
109
128
 
110
129
  function validateModelCatalog(value, method, { session = false } = {}) {
@@ -123,7 +142,9 @@ function validateModelCatalog(value, method, { session = false } = {}) {
123
142
  for (const model of group.models) {
124
143
  if (!model || typeof model !== 'object'
125
144
  || typeof model.id !== 'string' || !model.id
126
- || typeof model.name !== 'string' || !model.name) {
145
+ || typeof model.name !== 'string' || !model.name
146
+ || (model.description !== undefined && typeof model.description !== 'string')
147
+ || (model.reasoning !== undefined && !validModelReasoning(model.reasoning))) {
127
148
  throw new Error(`Harness returned an invalid response for ${method}`);
128
149
  }
129
150
  }
@@ -728,6 +749,9 @@ export class HarnessClient {
728
749
  sessionId,
729
750
  provider: selection.provider,
730
751
  model: selection.model,
752
+ ...(selection.reasoningEffort === undefined
753
+ ? {}
754
+ : { reasoningEffort: selection.reasoningEffort }),
731
755
  }, 30_000, signal ? { ...options, signal } : options);
732
756
  };
733
757
  const value = this.#sessionMaintenanceExecutor
@@ -236,13 +236,12 @@ export default {
236
236
  '/unwatch ID 取消关注': '/unwatch ID Stop watching a session',
237
237
  '🤖 预设 / 模型': '🤖 Presets / models',
238
238
  '/models 列出模型': '/models List models',
239
- '/model 2 按序号切换模型': '/model 2 Switch model by index',
240
239
  '🎮 任务控制': '🎮 Task controls',
241
240
  '/steer 指令 给 Agent 补充指令': '/steer INSTRUCTION Steer the Agent',
242
241
  '**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 补充指令 — 给 Agent 发送指令\n10. 🗄 归档切换 — 显示/隐藏归档会话\n11. 📊 状态 — 查看系统连接状态\n12. 📖 帮助 — 查看本帮助':
243
242
  '**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. Steer task — send an instruction to the Agent\n10. 🗄 Archived toggle — show or hide archived sessions\n11. 📊 Status — view connection status\n12. 📖 Help — view this help',
244
- '**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` — 列出会话\n`/workspace 路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/model 2` — 切换模型\n`/repair` — 修复卡片按钮':
245
- '**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` — list sessions\n`/workspace PATH` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/model 2` — switch model\n`/repair` — repair card buttons',
243
+ '**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` — 列出会话\n`/workspace 路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/repair` — 修复卡片按钮':
244
+ '**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` — list sessions\n`/workspace PATH` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/repair` — repair card buttons',
246
245
  '**💡 数字兜底**\n回复数字快速操作:\n**1**工作区列表 · **2**新会话 · **3**会话/关注\n**4**状态 · **5**修复 · **6**帮助':
247
246
  '**💡 Number fallback**\nReply with a number for a quick action:\n**1** Workspace list · **2** New session · **3** Sessions/watches\n**4** Status · **5** Repair · **6** Help',
248
247
  '从下方下拉选择补充指令;最后一项可自定义输入。':
@@ -50,9 +50,17 @@ export default {
50
50
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话':
51
51
  '/session <Session ID or workspace index> Bind this chat to the specified session',
52
52
  '/models 按序号列出所有可用模型': '/models List all available models by index',
53
+ '/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级':
54
+ '/reasoninglist or /reasonings List reasoning efforts for the current model by index',
55
+ '/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级':
56
+ '/reasoning [index, effort ID, or --default] Show or switch the current reasoning effort',
53
57
  '/model [序号或完整模型ID] 查看或切换当前会话模型':
54
58
  '/model [index or full model ID] Show or switch the model of the current session',
59
+ '/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型':
60
+ '/model [index or full model ID] [reasoning effort ID] Show or switch the current Session model',
55
61
  '示例:先发 /models,再发 /model 2': 'Example: send /models first, then /model 2',
62
+ '示例:先发 /models,再发 /model 2 [推理等级ID]':
63
+ 'Example: send /models first, then /model 2 [reasoning effort ID]',
56
64
  '/presetlist 按序号列出可用 Agent Preset': '/presetlist List available Agent Presets by index',
57
65
  '/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset':
58
66
  '/preset [index or full ID] Show or set the Agent Preset of this bot',
@@ -134,17 +134,39 @@ export default {
134
134
 
135
135
  // model-command.mjs
136
136
  '用法:/model <序号> 或 /model <provider>/<model>': 'Usage: /model <index> or /model <provider>/<model>',
137
+ '用法:/model <序号或 provider/model> [推理等级ID]':
138
+ 'Usage: /model <index or provider/model> [reasoning effort ID]',
137
139
  '用法:/models(不带参数)': 'Usage: /models (no arguments)',
140
+ '用法:/reasoning [序号、等级ID或 --default]':
141
+ 'Usage: /reasoning [index, effort ID, or --default]',
142
+ '用法:/reasoninglist 或 /reasonings(不带参数)':
143
+ 'Usage: /reasoninglist or /reasonings (no arguments)',
138
144
  '模型序号无效:{input}': 'Invalid model index: {input}',
139
145
  '请发送 /models 查看并输入有效的正整数序号。':
140
146
  'Please send /models to view the list and enter a valid positive integer index.',
147
+ '推理等级序号无效:{input}': 'Invalid reasoning effort index: {input}',
148
+ '请发送 /reasoninglist 查看并输入有效的正整数序号。':
149
+ 'Please send /reasoninglist and enter a valid positive integer index.',
141
150
  '可用模型:': 'Available models:',
151
+ '可用推理等级:': 'Available reasoning efforts:',
152
+ '可用推理等级:{efforts}': 'Available reasoning efforts: {efforts}',
142
153
  '当前没有可用模型。': 'No models are currently available.',
143
154
  '以下模型提供方暂时不可用:': 'The following model providers are temporarily unavailable:',
144
155
  '切换模型:/model <序号>': 'To switch models: /model <index>',
156
+ '切换模型:/model <序号> [推理等级ID]':
157
+ 'Switch model: /model <index> [reasoning effort ID]',
145
158
  '当前模型:': 'Current model:',
159
+ '当前推理等级:{effort}': 'Current reasoning effort: {effort}',
160
+ 'Default(由模型或 Provider 决定)': 'Default (determined by the model or Provider)',
161
+ '(当前、默认)': ' (current, default)',
162
+ '(默认)': ' (default)',
146
163
  '查看全部模型:/models': 'See all models: /models',
164
+ '查看可用推理等级:/reasoninglist': 'See reasoning efforts: /reasoninglist',
165
+ '切换推理等级:/reasoning <序号或等级ID>':
166
+ 'Switch reasoning effort: /reasoning <index or effort ID>',
167
+ '恢复默认等级:/reasoning --default': 'Restore the default effort: /reasoning --default',
147
168
  '当前聊天还没有会话。': 'This chat has no Session yet.',
169
+ '请先发送一条普通消息创建会话。': 'Send a regular message first to create a Session.',
148
170
  '查看模型:/models': 'View models: /models',
149
171
  '选择模型:/model <序号>': 'Select a model: /model <index>',
150
172
  '当前任务正在运行,请等待完成或先发送 /stop。':
@@ -153,22 +175,39 @@ export default {
153
175
  'The Session bound to this chat no longer exists; please try again.',
154
176
  '无法切换到该模型。模型当前不可用,或不支持当前会话中的图片。':
155
177
  'Cannot switch to that model. It is currently unavailable, or does not support the images in this Session.',
178
+ '无法切换推理等级。当前模型或推理等级不可用。':
179
+ 'Cannot switch the reasoning effort. The current model or effort is unavailable.',
156
180
  '当前聊天绑定的会话已发生变化,请重试。':
157
181
  'The Session bound to this chat has changed; please try again.',
158
182
  '获取模型列表已取消。': 'Fetching the model list was cancelled.',
183
+ '获取推理等级列表已取消。': 'Fetching the reasoning effort list was cancelled.',
159
184
  '模型切换已取消。': 'The model switch was cancelled.',
185
+ '推理等级切换已取消。': 'The reasoning effort switch was cancelled.',
160
186
  '暂时无法获取模型列表,请稍后重试。':
161
187
  'Unable to get the model list right now; please try again later.',
188
+ '暂时无法获取推理等级,请稍后重试。':
189
+ 'Unable to get reasoning efforts right now; please try again later.',
162
190
  '模型切换失败,请稍后重试。': 'Failed to switch the model; please try again later.',
191
+ '推理等级切换失败,请稍后重试。':
192
+ 'Failed to switch the reasoning effort; please try again later.',
163
193
  '模型命令仅支持纯文字,请移除图片后重试。':
164
194
  'Model commands support text only; please remove images and try again.',
195
+ '模型和推理等级命令仅支持纯文字,请移除图片后重试。':
196
+ 'Model and reasoning effort commands support text only; please remove images and try again.',
165
197
  '当前任务正在等待你的回答或审批。': 'The current task is waiting for your answer or approval.',
166
198
  '请先处理当前请求,或者发送 /stop 停止任务。':
167
199
  'Please handle the current request first, or send /stop to stop the task.',
168
200
  '没有找到模型:{model}': 'Model not found: {model}',
169
201
  '请发送 /models 查看可用模型。': 'Please send /models to view available models.',
202
+ '模型不支持推理等级:{effort}': 'The model does not support reasoning effort: {effort}',
203
+ '该模型不提供可切换的推理等级。':
204
+ 'This model does not provide switchable reasoning efforts.',
170
205
  '模型已切换为:\n{model}\n\n后续消息将使用该模型。':
171
206
  'Model switched to:\n{model}\n\nSubsequent messages will use this model.',
207
+ '模型已切换为:\n{model}\n推理等级:{effort}\n\n后续消息将使用该模型和推理等级。':
208
+ 'Model switched to:\n{model}\nReasoning effort: {effort}\n\nSubsequent messages will use this model and reasoning effort.',
209
+ '推理等级已切换为:\n{effort}\n\n当前模型:{model}\n后续消息将使用该推理等级。':
210
+ 'Reasoning effort switched to:\n{effort}\n\nCurrent model: {model}\nSubsequent messages will use this reasoning effort.',
172
211
 
173
212
  // compact-command.mjs
174
213
  '用法:/compact(不带参数)': 'Usage: /compact (no arguments)',