feat: add plugin version command

This commit is contained in:
xmanrui 2026-08-25 12:25:01 +08:00
parent 10ca7eec2c
commit c5becbe379
24 changed files with 240 additions and 172 deletions

View file

@ -138,6 +138,7 @@ Each WhatsApp bot also has its own access mode. Existing bots migrate to **Only
| `/help` | Show the commands and usage supported by the bot. |
| `/new` | Unbind the current chat so its next ordinary message starts a new Harness Session. |
| `/status` | Check the connection between the current bot and DeepSeek Harness. |
| `/version` | Show the version of the running dsh-im plugin. |
| `/models` | List every currently configured model with a number. |
| `/model` | Show the model and reasoning effort used by the Session bound to this chat. |
| `/model <number or provider/model-id> [reasoning effort ID]` | Switch the Session model and optionally select an effort supported by the target model. |
@ -163,7 +164,7 @@ Each WhatsApp bot also has its own access mode. Existing bots migrate to **Only
| Interactive question | Reply with an option number, option label, or custom text; separate multiple choices with commas. |
| Remote approval | Reply with `批准` / `拒绝` / `同意` / `不同意` / `yes` / `no`. |
Example: send `/models`, then `/model 2` to switch to the second model in the list; send `/reasoninglist`, then `/reasoning 2` to switch to the current model's second reasoning effort; send `/presetlist`, then `/preset 2` to select the second Agent Preset for this bot. Other examples: `/help`, `/new`, `/status`, `/model deepseek-official/deepseek-v4-pro max`, `/reasoning --default`, `/preset marketing-jeep`, `/preset --default`, `/steer inspect only the configuration file`, `/stop`, `/compact`, `/workspace /Users/alice/projects/my-app`, `/sessionlist 2`, `/sessionlist /Users/alice/projects/my-app`, or `/session session-id`
Example: send `/models`, then `/model 2` to switch to the second model in the list; send `/reasoninglist`, then `/reasoning 2` to switch to the current model's second reasoning effort; send `/presetlist`, then `/preset 2` to select the second Agent Preset for this bot. Other examples: `/help`, `/new`, `/status`, `/version`, `/model deepseek-official/deepseek-v4-pro max`, `/reasoning --default`, `/preset marketing-jeep`, `/preset --default`, `/steer inspect only the configuration file`, `/stop`, `/compact`, `/workspace /Users/alice/projects/my-app`, `/sessionlist 2`, `/sessionlist /Users/alice/projects/my-app`, or `/session session-id`
If the Slack desktop app has no native Slash Command registered with the same name, it intercepts messages that begin directly with `/`. Send the command with one leading space instead, for example ` /presetlist` or ` /preset 2`; the plugin command layer trims surrounding whitespace, so it executes exactly like the unspaced form.
@ -171,6 +172,7 @@ If the Slack desktop app has no native Slash Command registered with the same na
- `/help` takes no arguments and never creates a Session. It returns the complete command list supported by the current bot.
- `/status` takes no arguments, never prompts the model, and does not change the Session binding. It confirms that the current bot can reach DeepSeek Harness.
- `/version` takes no arguments and never contacts Harness, creates a Session, or prompts the model. It returns the version of the running dsh-im plugin.
- `/new` only removes the current chat's saved dsh-im Session binding; it never deletes, empties, or archives the old Session. The next ordinary message creates and binds a new Session in the current workspace. If a task is running or waiting for a question or approval, finish the interaction or use `/stop` before `/new`.
- `/models` takes no arguments and never creates a Session. It assigns a number to every currently configured Harness model and also shows its stable, copyable `provider/model-id`. If one provider fails, models from the remaining providers are still shown.
- Bare `/model` shows the current Session model and reasoning effort. With arguments, it accepts a number from `/models` or an exact full model ID, followed optionally by an exact reasoning effort ID published in that target model's metadata, for example `/model 2 max`. When the effort is omitted, Harness resolves the target model's current default. If the chat has no Session yet, a valid switch creates and binds a blank Session without prompting the model.

View file

@ -141,6 +141,7 @@ dsh web
| `/help` | 显示机器人支持的命令和用法。 |
| `/new` | 解除当前聊天的会话绑定,让下一条普通消息开启全新 Harness 会话。 |
| `/status` | 检查当前机器人与 DeepSeek Harness 的连接状态。 |
| `/version` | 查看当前运行的 dsh-im 插件版本。 |
| `/models` | 按序号列出当前配置的全部可用模型。 |
| `/model` | 查看当前聊天绑定会话正在使用的模型和推理等级。 |
| `/model <序号或 Provider/模型ID> [推理等级ID]` | 切换当前会话模型,并可同时指定目标模型支持的推理等级。 |
@ -166,7 +167,7 @@ dsh web
| 交互式提问 | 回复选项序号、选项文字或自定义文字;多选时用逗号分隔。 |
| 远程审批 | 回复 `批准` / `拒绝` / `同意` / `不同意` / `yes` / `no`。 |
示例:先发送 `/models`,再发送 `/model 2` 切换到列表中的第 2 个模型;先发送 `/reasoninglist`,再发送 `/reasoning 2` 切换到当前模型的第 2 个推理等级;先发送 `/presetlist`,再发送 `/preset 2` 为当前机器人选择第 2 个 Agent Preset。其他命令示例:`/help`、`/new`、`/status`、`/model deepseek-official/deepseek-v4-pro max`、`/reasoning --default`、`/preset marketing-jeep`、`/preset --default`、`/steer 只检查配置文件`、`/stop`、`/compact`、`/workspace /Users/alice/projects/my-app`、`/sessionlist 2`、`/sessionlist /Users/alice/projects/my-app` 或 `/session session-id`
示例:先发送 `/models`,再发送 `/model 2` 切换到列表中的第 2 个模型;先发送 `/reasoninglist`,再发送 `/reasoning 2` 切换到当前模型的第 2 个推理等级;先发送 `/presetlist`,再发送 `/preset 2` 为当前机器人选择第 2 个 Agent Preset。其他命令示例:`/help`、`/new`、`/status`、`/version`、`/model deepseek-official/deepseek-v4-pro max`、`/reasoning --default`、`/preset marketing-jeep`、`/preset --default`、`/steer 只检查配置文件`、`/stop`、`/compact`、`/workspace /Users/alice/projects/my-app`、`/sessionlist 2`、`/sessionlist /Users/alice/projects/my-app` 或 `/session session-id`
Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/` 开头的消息。此时请加一个前导空格发送,例如 ` /presetlist` 或 ` /preset 2`;插件命令层会去除首尾空白,执行效果与无空格命令相同。
@ -174,6 +175,7 @@ Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/
- `/help` 不需要参数,也不会创建会话;它会返回当前机器人支持的完整命令列表。
- `/status` 不需要参数,也不会向模型发送消息或改变会话绑定;它用于确认当前机器人能够连接 DeepSeek Harness。
- `/version` 不需要参数,也不会访问 Harness、创建会话或调用模型;它返回当前运行的 dsh-im 插件版本。
- `/new` 只解除当前聊天在 dsh-im 中保存的会话绑定,不会删除、清空或归档旧 Session。下一条普通消息会在当前工作区创建并绑定一个新 Session。任务正在运行或等待问题、审批时,应先完成交互或使用 `/stop`,再使用 `/new`。
- `/models` 不需要参数,也不会创建会话。它为 Harness 当前配置的全部可用模型分配序号,同时显示可稳定复制的 `Provider/模型ID`;某个 Provider 查询失败时,其他 Provider 的结果仍会显示。
- `/model` 不带参数时查看当前会话的模型和推理等级;带参数时接受 `/models` 列出的序号或精确完整模型 ID,并可追加目标模型元数据公布的精确推理等级 ID,例如 `/model 2 max`。省略推理等级时,由 Harness 解析目标模型的当前默认值。聊天尚无会话时,有效的切换命令会创建并绑定一个空白会话,但不会触发模型回复。

File diff suppressed because one or more lines are too long

View file

@ -77,6 +77,7 @@ const HELP_TEXT_LINES = [
'/send 提交当前批次',
'/cancel 取消当前批次',
'/status 检查连接状态',
'/version 查看插件版本',
'/help 显示本帮助',
];

View file

@ -495,6 +495,7 @@ export function menuHelpText() {
'',
'📊 状态 / 压缩',
'/status 连接状态',
'/version 查看插件版本',
'/compact 压缩当前会话上下文',
'/archived on/off 会话列表显示/隐藏归档',
'',
@ -589,7 +590,10 @@ export function helpCard(extraTextLines = []) {
const elements = [
{ tag: 'div', text: markdown(t(HELP_CARD_FEATURES)) },
{ tag: 'hr' },
{ tag: 'div', text: markdown(t(HELP_TEXT_COMMANDS) + extraText) },
{ tag: 'div', text: markdown([
t(HELP_TEXT_COMMANDS),
t('`/version` — 查看插件版本'),
].join('\n') + extraText) },
{ tag: 'hr' },
{ tag: 'div', text: markdown(t(HELP_NUMBER_FALLBACK)) },
{ tag: 'hr' },

View file

@ -90,6 +90,7 @@ function helpText() {
t('/send 提交当前批次'),
t('/cancel 取消当前批次'),
t('/status 检查连接状态'),
t('/version 查看插件版本'),
t('/help 显示本帮助'),
].join('\n');
}

View file

@ -1,8 +1,11 @@
import { t } from './i18n.mjs';
import manifest from '../../../package.json' with { type: 'json' };
const CONTROL_COMMAND = /^\/(?:stop|steer)(?=$|\s)/iu;
const CONTROL_COMMAND = /^\/(?:stop|steer|version)(?=$|\s)/iu;
const STOP_COMMAND = /^\/stop(?=$|\s)/iu;
const VERSION_COMMAND = /^\/version(?=$|\s)/iu;
const STOP_USAGE = '用法:/stop(不带参数)';
const VERSION_USAGE = '用法:/version(不带参数)';
const STEER_USAGE = '用法:/steer <补充指令>';
const TEXT_ONLY = '控制命令仅支持纯文字,请移除图片后重试。';
@ -41,9 +44,16 @@ export async function runControlCommand(text, harness, state, key, {
if (!isControlCommand(text)) return null;
const command = text.trim();
const stop = STOP_COMMAND.test(command);
const version = VERSION_COMMAND.test(command);
if (hasImages) return commandResult(t(TEXT_ONLY));
if (version) {
return /^\/version$/iu.test(command)
? commandResult(`dsh-im v${manifest.version}`)
: commandResult(t(VERSION_USAGE));
}
if (stop) {
if (!/^\/stop$/iu.test(command)) return commandResult(t(STOP_USAGE));
const session = boundSession(harness, state, key);

View file

@ -229,6 +229,7 @@ export default {
'/new 开启全新会话': '/new Start a new session',
'📊 状态 / 压缩': '📊 Status / compact',
'/status 连接状态': '/status Connection status',
'`/version` — 查看插件版本': '`/version` — show the plugin version',
'/compact 压缩当前会话上下文': '/compact Compact the current session context',
'/archived on/off 会话列表显示/隐藏归档': '/archived on/off Show/hide archived sessions',
'👁 关注': '👁 Watches',

View file

@ -69,6 +69,7 @@ export default {
'/stop 停止当前任务': '/stop Stop the current task',
'/steer 补充指令 纠偏当前任务': '/steer <additional instruction> Steer the current task',
'/status 检查连接状态': '/status Check the connection status',
'/version 查看插件版本': '/version Show the plugin version',
'/help 显示本帮助': '/help Show this help',
'{label}机器人与 DeepSeek Harness 连接正常。':
'The {label} bot is connected to DeepSeek Harness and working normally.',

View file

@ -242,6 +242,7 @@ export default {
// control-command.mjs
'用法:/stop(不带参数)': 'Usage: /stop (no arguments)',
'用法:/version(不带参数)': 'Usage: /version (no arguments)',
'用法:/steer <补充指令>': 'Usage: /steer <additional instruction>',
'控制命令仅支持纯文字,请移除图片后重试。':
'Control commands support text only; please remove images and try again.',

View file

@ -13,6 +13,7 @@ export default {
'停止当前任务': 'Stop the current task',
'纠偏当前任务': 'Steer the current task',
'检查连接状态': 'Check connection status',
'查看插件版本': 'Show plugin version',
'显示帮助': 'Show help',
'该 Telegram 机器人已配置 Webhook,请先在原服务中移除 Webhook 后重试。':
'This Telegram bot already has a Webhook configured. Remove the Webhook from the original service and try again.',

View file

@ -494,6 +494,7 @@ export class TextHarnessBridge {
t('/send 提交当前批次'),
t('/cancel 取消当前批次'),
t('/status 检查连接状态'),
t('/version 查看插件版本'),
t('/help 显示本帮助'),
].join('\n'));
return;

View file

@ -32,6 +32,7 @@ export const TELEGRAM_COMMAND_MENU = Object.freeze([
{ command: 'send', description: '提交当前批次' },
{ command: 'cancel', description: '取消当前批次' },
{ command: 'status', description: '检查连接状态' },
{ command: 'version', description: '查看插件版本' },
{ command: 'help', description: '显示帮助' },
]);

View file

@ -73,6 +73,7 @@ function helpText() {
t('/send 提交当前批次'),
t('/cancel 取消当前批次'),
t('/status 检查连接状态'),
t('/version 查看插件版本'),
t('/help 显示本帮助'),
].join('\n');
}

View file

@ -79,6 +79,7 @@ const HELP_TEXT = () => [
t('/send 提交当前批次'),
t('/cancel 取消当前批次'),
t('/status 检查连接状态'),
t('/version 查看插件版本'),
t('/help 显示本帮助'),
].join('\n');

View file

@ -841,6 +841,7 @@ test('DingTalk lists models and presets without prompting and advertises fast co
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
'/version',
]) {
assert.equal(help.includes(command), true, command);
}

View file

@ -390,7 +390,7 @@ test('Feishu lists models and presets without prompting and advertises fast comm
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/batch', '/send', '/cancel',
'/stop', '/steer',
'/stop', '/steer', '/version',
]) {
assert.equal(help.includes(command), true, command);
}

View file

@ -76,6 +76,7 @@ test('menu and card help advertise Agent Preset, reasoning, and batch commands',
assert.match(help, /\/batch/);
assert.match(help, /\/send/);
assert.match(help, /\/cancel/);
assert.match(help, /\/version/);
const card = helpCard();
assert.match(card, /\/reasoninglist/);

View file

@ -673,6 +673,7 @@ test('QQ lists models and presets without prompting and advertises fast commands
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
'/version',
]) {
assert.equal(help.includes(command), true, command);
}

View file

@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import manifest from '../../../package.json' with { type: 'json' };
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
@ -794,7 +795,7 @@ test('all four shared text channels list models and presets locally and advertis
const help = sent.at(-1);
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer', '/version',
]) {
assert.match(help, new RegExp(`\\${command}`), `${name} ${command}`);
}
@ -805,6 +806,21 @@ test('all four shared text channels list models and presets locally and advertis
}
});
test('/version uses the shared command fast lane without accessing Harness', async () => {
const fixture = stateFixture();
const sent = [];
const bridge = createBridge({
state: fixture.state,
bot: { sendText: async (_target, text) => sent.push(text) },
harness: {},
});
await bridge.accept(message('plugin-version', '/version'));
assert.deepEqual(sent, [`dsh-im v${manifest.version}`]);
assert.equal(fixture.sessions.size, 0);
});
test('/stop uses the shared command fast lane without waiting for the running prompt', async () => {
const fixture = stateFixture({ 'direct:chat-a': 'session-running' });
const askStarted = deferred();

View file

@ -251,6 +251,7 @@ test('Telegram API registers the command menu and commands-type menu button', as
test('Telegram command menu follows the host language at runtime', () => {
assert.equal(TELEGRAM_COMMAND_MENU[0].description, '开启一个全新会话');
assert.equal(TELEGRAM_COMMAND_MENU.some(({ command }) => command === 'version'), true);
setImHostLanguage('en');
try {
assert.equal(telegramCommandMenu()[0].description, 'Start a brand-new Session');

View file

@ -462,6 +462,7 @@ test('Enterprise WeChat lists models and presets without prompting and advertise
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
'/version',
'/batch', '/send', '/cancel',
]) {
assert.equal(help.includes(command), true, command);

View file

@ -778,6 +778,7 @@ test('Weixin lists models and presets without prompting and advertises fast comm
for (const command of [
'/models', '/model', '/reasoninglist', '/reasonings', '/reasoning',
'/presetlist', '/preset', '/preset --default', '/stop', '/steer',
'/version',
'/batch', '/send', '/cancel',
]) {
assert.equal(help.includes(command), true, command);

View file

@ -36,14 +36,31 @@ function fixture({ sessionId = 'session-one', stopped = true, steered = true } =
test('isControlCommand reserves valid and malformed control command forms', () => {
for (const value of [
'/stop', ' /STOP ', '/stop now', '/steer', '/StEeR do this', '/steer line one\nline two',
'/version', ' /VERSION ', '/version now',
]) {
assert.equal(isControlCommand(value), true, value);
}
for (const value of [null, '', 'stop', '/stopping', '/steering', 'hello /stop']) {
for (const value of [null, '', 'stop', '/stopping', '/steering', '/versions', 'hello /stop']) {
assert.equal(isControlCommand(value), false, String(value));
}
});
test('/version returns the package version without touching Harness or Session state', async () => {
const { calls, harness, state } = fixture();
const { version } = await import('../package.json', { with: { type: 'json' } })
.then((module) => module.default);
assert.deepEqual(
await runControlCommand('/VERSION', harness, state, 'direct:one'),
{ message: `dsh-im v${version}` },
);
assert.match(
(await runControlCommand('/version details', harness, state, 'direct:one')).message,
/用法/,
);
assert.equal(calls.length, 0);
});
test('/stop is exact, text-only, and never creates a Session', async () => {
const { calls, harness, state } = fixture();
assert.match((await runControlCommand('/stop later', harness, state, 'direct:one')).message, /用法/);