mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 13:10:44 +08:00
feat: add model and turn control commands
This commit is contained in:
parent
fc8f120e13
commit
99ce38e9a4
38 changed files with 3451 additions and 169 deletions
314
src/channels/shared/model-command.mjs
Normal file
314
src/channels/shared/model-command.mjs
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
|
||||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||||
import { withSessionBindingLock } from './session-binding-lock.mjs';
|
||||
|
||||
const MODEL_COMMAND = /^\/model(?=$|\s)/i;
|
||||
const MODELS_COMMAND = /^\/models(?=$|\s)/i;
|
||||
const MODEL_USAGE = '用法:/model <provider>/<model>';
|
||||
const MODELS_USAGE = '用法:/models(不带参数)';
|
||||
const SESSION_BINDING_CHANGED = 'session-binding-changed';
|
||||
const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
|
||||
|
||||
function commandResult(message) {
|
||||
return {
|
||||
handled: true,
|
||||
message,
|
||||
messages: splitWorkspaceCommandMessage(message),
|
||||
};
|
||||
}
|
||||
|
||||
function safeDisplayText(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.replace(UNSAFE_DISPLAY_TEXT_GLOBAL, ' ').replace(/\s+/gu, ' ').trim();
|
||||
}
|
||||
|
||||
function rpcOptions(signal) {
|
||||
return signal ? { signal } : {};
|
||||
}
|
||||
|
||||
function normalizeCatalog(value, { requireCurrent = false } = {}) {
|
||||
if (!value || typeof value !== 'object'
|
||||
|| !Array.isArray(value.groups) || !Array.isArray(value.failures)) {
|
||||
throw new TypeError('Harness returned an invalid model catalog');
|
||||
}
|
||||
const groups = value.groups.map((group) => {
|
||||
if (!group || typeof group !== 'object'
|
||||
|| typeof group.id !== 'string' || !group.id
|
||||
|| typeof group.name !== 'string' || !group.name
|
||||
|| !Array.isArray(group.models)) {
|
||||
throw new TypeError('Harness returned an invalid model provider group');
|
||||
}
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
models: group.models.map((model) => {
|
||||
if (!model || typeof model !== 'object'
|
||||
|| typeof model.id !== 'string' || !model.id
|
||||
|| typeof model.name !== 'string' || !model.name) {
|
||||
throw new TypeError('Harness returned an invalid model');
|
||||
}
|
||||
return { id: model.id, name: model.name };
|
||||
}),
|
||||
};
|
||||
});
|
||||
const failures = value.failures.map((failure) => {
|
||||
if (!failure || typeof failure !== 'object'
|
||||
|| typeof failure.id !== 'string' || !failure.id
|
||||
|| typeof failure.name !== 'string' || !failure.name) {
|
||||
throw new TypeError('Harness returned an invalid model provider failure');
|
||||
}
|
||||
return { id: failure.id, name: failure.name };
|
||||
});
|
||||
let current = null;
|
||||
if (value.current !== undefined) {
|
||||
if (!value.current || typeof value.current !== 'object'
|
||||
|| typeof value.current.provider !== 'string' || !value.current.provider
|
||||
|| typeof value.current.model !== 'string' || !value.current.model) {
|
||||
throw new TypeError('Harness returned an invalid current model');
|
||||
}
|
||||
current = { provider: value.current.provider, model: value.current.model };
|
||||
} else if (requireCurrent) {
|
||||
throw new TypeError('Harness returned no current model');
|
||||
}
|
||||
return { groups, failures, current };
|
||||
}
|
||||
|
||||
function modelId(provider, model) {
|
||||
return `${provider}/${model}`;
|
||||
}
|
||||
|
||||
function matchingModel(catalog, requested) {
|
||||
for (const group of catalog.groups) {
|
||||
for (const model of group.models) {
|
||||
if (modelId(group.id, model.id) === requested) {
|
||||
return { provider: group.id, model: model.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatCatalog(catalog) {
|
||||
const currentId = catalog.current
|
||||
? modelId(catalog.current.provider, catalog.current.model)
|
||||
: null;
|
||||
const lines = ['可用模型:'];
|
||||
if (catalog.groups.length === 0) lines.push('', '当前没有可用模型。');
|
||||
for (const group of catalog.groups) {
|
||||
lines.push('', safeDisplayText(group.name) || safeDisplayText(group.id));
|
||||
for (const model of group.models) {
|
||||
const id = modelId(group.id, model.id);
|
||||
lines.push(`- ${safeDisplayText(id)}${id === currentId ? '(当前)' : ''}`);
|
||||
}
|
||||
}
|
||||
if (catalog.failures.length > 0) {
|
||||
lines.push('', '以下模型提供方暂时不可用:');
|
||||
for (const failure of catalog.failures) {
|
||||
lines.push(`- ${safeDisplayText(failure.name) || safeDisplayText(failure.id)}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function currentModelMessage(current) {
|
||||
return [
|
||||
'当前模型:',
|
||||
modelId(current.provider, current.model),
|
||||
'',
|
||||
'查看全部模型:/models',
|
||||
'切换模型:/model <provider>/<model>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function noSessionMessage() {
|
||||
return [
|
||||
'当前聊天还没有会话。',
|
||||
'',
|
||||
'查看模型:/models',
|
||||
'选择模型:/model <provider>/<model>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function errorCode(error) {
|
||||
return error?.code ?? error?.failure?.code;
|
||||
}
|
||||
|
||||
function modelErrorMessage(error, action) {
|
||||
const code = errorCode(error);
|
||||
if (code === 'agent-busy') {
|
||||
return '当前任务正在运行,请等待完成或先发送 /stop。';
|
||||
}
|
||||
if (code === 'session-not-found') {
|
||||
return '当前聊天绑定的会话已不存在,请重试。';
|
||||
}
|
||||
if (code === 'model-unavailable') {
|
||||
return '无法切换到该模型。模型当前不可用,或不支持当前会话中的图片。';
|
||||
}
|
||||
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
|
||||
return '工作区或机器人状态已发生变化,请重试。';
|
||||
}
|
||||
if (code === SESSION_BINDING_CHANGED) {
|
||||
return '当前聊天绑定的会话已发生变化,请重试。';
|
||||
}
|
||||
if (code === 'cancelled' || error?.name === 'AbortError') {
|
||||
return action === 'list' ? '获取模型列表已取消。' : '模型切换已取消。';
|
||||
}
|
||||
return action === 'list'
|
||||
? '暂时无法获取模型列表,请稍后重试。'
|
||||
: '模型切换失败,请稍后重试。';
|
||||
}
|
||||
|
||||
async function boundSession(harness, state, key, options) {
|
||||
if (typeof state?.sessionFor !== 'function') return null;
|
||||
const sessionId = state.sessionFor(key);
|
||||
if (typeof sessionId !== 'string' || !sessionId) return null;
|
||||
if (typeof harness?.workspaceSession !== 'function') {
|
||||
throw new TypeError('Harness does not support workspace sessions');
|
||||
}
|
||||
const session = harness.workspaceSession(sessionId);
|
||||
if (!session || typeof session.sessionExists !== 'function') {
|
||||
throw new TypeError('Harness returned an invalid workspace session');
|
||||
}
|
||||
if (await session.sessionExists(options)) return { sessionId, session };
|
||||
if (typeof state.clearSession === 'function' && state.sessionFor(key) === sessionId) {
|
||||
await state.clearSession(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sessionIsBusy(session, control, options) {
|
||||
if (typeof session?.isRunning !== 'function'
|
||||
|| typeof session?.hasActiveTurn !== 'function') {
|
||||
throw new TypeError('Harness session does not expose run state');
|
||||
}
|
||||
if (await session.isRunning(options)) return true;
|
||||
return Boolean(await session.hasActiveTurn(control, options));
|
||||
}
|
||||
|
||||
async function listCatalog(harness, options) {
|
||||
if (typeof harness?.listModels !== 'function') {
|
||||
throw new TypeError('Harness does not support listing models');
|
||||
}
|
||||
return normalizeCatalog(await harness.listModels(options));
|
||||
}
|
||||
|
||||
async function sessionCatalog(session, options) {
|
||||
if (typeof session?.models !== 'function') {
|
||||
throw new TypeError('Harness session does not support listing models');
|
||||
}
|
||||
return normalizeCatalog(await session.models(options), { requireCurrent: true });
|
||||
}
|
||||
|
||||
function isModelsCommand(command) {
|
||||
return MODELS_COMMAND.test(command);
|
||||
}
|
||||
|
||||
export function isModelCommand(text) {
|
||||
if (typeof text !== 'string') return false;
|
||||
const command = text.trim();
|
||||
return MODELS_COMMAND.test(command) || MODEL_COMMAND.test(command);
|
||||
}
|
||||
|
||||
export async function runModelCommand(text, harness, state, key, options = {}) {
|
||||
if (!isModelCommand(text)) return null;
|
||||
const command = text.trim();
|
||||
if (options.hasImages) {
|
||||
return commandResult('模型命令仅支持纯文字,请移除图片后重试。');
|
||||
}
|
||||
const requestOptions = rpcOptions(options.signal);
|
||||
|
||||
if (isModelsCommand(command)) {
|
||||
if (!/^\/models[ \t]*$/iu.test(command)) return commandResult(MODELS_USAGE);
|
||||
try {
|
||||
const bound = await boundSession(harness, state, key, requestOptions);
|
||||
const catalog = bound
|
||||
? await sessionCatalog(bound.session, requestOptions)
|
||||
: await listCatalog(harness, requestOptions);
|
||||
return commandResult(formatCatalog(catalog));
|
||||
} catch (error) {
|
||||
return commandResult(modelErrorMessage(error, 'list'));
|
||||
}
|
||||
}
|
||||
|
||||
const match = /^\/model(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(command);
|
||||
if (!match) return commandResult(MODEL_USAGE);
|
||||
const requested = match[1];
|
||||
if (!requested) {
|
||||
try {
|
||||
const bound = await boundSession(harness, state, key, requestOptions);
|
||||
if (!bound) return commandResult(noSessionMessage());
|
||||
const catalog = await sessionCatalog(bound.session, requestOptions);
|
||||
return commandResult(currentModelMessage(catalog.current));
|
||||
} catch (error) {
|
||||
return commandResult(modelErrorMessage(error, 'select'));
|
||||
}
|
||||
}
|
||||
if (!requested.includes('/') || requested.startsWith('/') || requested.endsWith('/')) {
|
||||
return commandResult(MODEL_USAGE);
|
||||
}
|
||||
if (options.pendingInteraction) {
|
||||
return commandResult([
|
||||
'当前任务正在等待你的回答或审批。',
|
||||
'',
|
||||
'请先处理当前请求,或者发送 /stop 停止任务。',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
try {
|
||||
return await withSessionBindingLock(state, key, async () => {
|
||||
const bound = await boundSession(harness, state, key, requestOptions);
|
||||
if (bound && await sessionIsBusy(bound.session, options.control, requestOptions)) {
|
||||
return commandResult('当前任务正在运行,请等待完成或先发送 /stop。');
|
||||
}
|
||||
|
||||
const catalog = bound
|
||||
? await sessionCatalog(bound.session, requestOptions)
|
||||
: await listCatalog(harness, requestOptions);
|
||||
const selection = matchingModel(catalog, requested);
|
||||
if (!selection) {
|
||||
return commandResult([
|
||||
`没有找到模型:${safeDisplayText(requested)}`,
|
||||
'',
|
||||
'请发送 /models 查看可用模型。',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
if (bound) {
|
||||
if (typeof bound.session.selectModel !== 'function') {
|
||||
throw new TypeError('Harness session does not support model selection');
|
||||
}
|
||||
await bound.session.selectModel(selection, requestOptions);
|
||||
} else {
|
||||
if (typeof harness?.createSession !== 'function'
|
||||
|| typeof harness?.workspaceSession !== 'function'
|
||||
|| typeof state?.sessionFor !== 'function'
|
||||
|| typeof state?.setSession !== 'function') {
|
||||
throw new TypeError('Harness cannot create a conversation session');
|
||||
}
|
||||
const sessionId = await harness.createSession(requestOptions);
|
||||
if (typeof sessionId !== 'string' || !sessionId) {
|
||||
throw new TypeError('Harness returned an invalid session id');
|
||||
}
|
||||
const session = harness.workspaceSession(sessionId);
|
||||
if (!session || typeof session.selectModel !== 'function') {
|
||||
throw new TypeError('Harness session does not support model selection');
|
||||
}
|
||||
await session.selectModel(selection, requestOptions);
|
||||
const currentSessionId = state.sessionFor(key);
|
||||
if (typeof currentSessionId === 'string' && currentSessionId) {
|
||||
const changed = new Error('Conversation binding changed during model selection');
|
||||
changed.code = SESSION_BINDING_CHANGED;
|
||||
throw changed;
|
||||
}
|
||||
if (await state.setSession(key, sessionId) === false) {
|
||||
const stale = new Error('Workspace changed while binding the new session');
|
||||
stale.code = WORKSPACE_SESSION_STALE;
|
||||
throw stale;
|
||||
}
|
||||
}
|
||||
return commandResult(`模型已切换为:\n${modelId(selection.provider, selection.model)}\n\n后续消息将使用该模型。`);
|
||||
});
|
||||
} catch (error) {
|
||||
return commandResult(modelErrorMessage(error, 'select'));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue