mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 02:53:20 +08:00
fix(feishu): stabilize post-merge card workflows
This commit is contained in:
parent
9d1dc71794
commit
bd9c58cf9e
19 changed files with 3372 additions and 456 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -831,6 +831,9 @@ export function customSteerCard() {
|
|||
},
|
||||
{
|
||||
tag: 'button',
|
||||
// Card 2.0 form controls need stable names so the client can
|
||||
// construct a submit action (and expose action.name) reliably.
|
||||
name: 'steer_submit',
|
||||
text: { tag: 'plain_text', content: t('提交') },
|
||||
type: 'primary',
|
||||
width: 'fill',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const CALLBACK_PROBE_SUCCESS_NOTICE = '✅ 修复完成:已实测收到 card.a
|
|||
const CALLBACK_PROBE_TIMEOUT_NOTICE = '⚠️ 修复验证超时:未收到测试卡按钮的 card.action.trigger,不能确认按钮已修复。请不要重复授权;先检查飞书开放平台的卡片回调配置,确认后再发送 /repair。';
|
||||
const CALLBACK_PROBE_SEND_FAILURE_NOTICE = '⚠️ 修复验证失败:无法发送专用测试卡,不能确认 card.action.trigger 已恢复。请不要重复授权;先检查机器人消息权限和连接状态。';
|
||||
const CALLBACK_PROBE_ABORT_NOTICE = '⚠️ 修复验证中断:Runtime 已停止,未完成 card.action.trigger 实测,不能确认修复成功。请不要重复授权;先等待机器人恢复连接。';
|
||||
const REUSABLE_WS_STATES = new Set(['connected', 'connecting', 'reconnecting']);
|
||||
|
||||
function nonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -24,6 +25,15 @@ function strictCardOperatorOpenId(event) {
|
|||
?? nonEmptyString(event?.operator?.operator_id?.open_id);
|
||||
}
|
||||
|
||||
function websocketState(wsClient, fallback) {
|
||||
try {
|
||||
const state = wsClient?.getConnectionStatus?.()?.state;
|
||||
return typeof state === 'string' && state ? state : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function probeError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
|
|
@ -104,6 +114,7 @@ export class FeishuRuntime {
|
|||
#bridge = null;
|
||||
#wsClient = null;
|
||||
#starting = null;
|
||||
#stopping = null;
|
||||
#abortController = null;
|
||||
#pendingCardActionProbes = new Map();
|
||||
#status;
|
||||
|
|
@ -170,25 +181,59 @@ export class FeishuRuntime {
|
|||
}
|
||||
|
||||
async start() {
|
||||
if (this.#wsClient && this.#status.ready) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
while (true) {
|
||||
while (this.#stopping) await this.#stopping;
|
||||
if (this.#starting) return this.#starting;
|
||||
|
||||
this.#starting = this.#start().finally(() => {
|
||||
this.#starting = null;
|
||||
const wsClient = this.#wsClient;
|
||||
if (wsClient) {
|
||||
const state = websocketState(wsClient, this.#status.feishuLongConnectionState);
|
||||
if (REUSABLE_WS_STATES.has(state)) return this.status;
|
||||
|
||||
await this.stop({ preserveError: state === 'failed' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// A partial/failed attempt may have created resources before its
|
||||
// WSClient became observable. Drain them before assigning a new attempt.
|
||||
if (this.#client || this.#bridge || this.#abortController) {
|
||||
await this.stop({
|
||||
preserveError: this.#status.feishuLongConnectionState === 'failed',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
let starting;
|
||||
starting = this.#start().finally(() => {
|
||||
if (this.#starting === starting) this.#starting = null;
|
||||
});
|
||||
return this.#starting;
|
||||
this.#starting = starting;
|
||||
return starting;
|
||||
}
|
||||
|
||||
async #start() {
|
||||
const abortController = new AbortController();
|
||||
this.#abortController = abortController;
|
||||
const { signal } = abortController;
|
||||
const abortError = () => (
|
||||
signal.reason ?? new DOMException('Feishu runtime stopped', 'AbortError')
|
||||
);
|
||||
const isCurrentStart = () => (
|
||||
!signal.aborted && this.#abortController === abortController
|
||||
);
|
||||
const assertCurrentStart = () => {
|
||||
if (!isCurrentStart()) throw abortError();
|
||||
};
|
||||
this.#status.startedAt = new Date().toISOString();
|
||||
this.#status.feishuLongConnectionState = 'connecting';
|
||||
this.#status.lastError = null;
|
||||
|
||||
try {
|
||||
await this.#harness.ensureRunning({ signal });
|
||||
assertCurrentStart();
|
||||
this.#status.harnessReachable = true;
|
||||
|
||||
const sdkDomain = this.#domain === 'lark'
|
||||
|
|
@ -204,13 +249,14 @@ export class FeishuRuntime {
|
|||
this.#requestTimeoutMs,
|
||||
);
|
||||
if (httpInstance) larkConfig.httpInstance = httpInstance;
|
||||
this.#client = new this.#lark.Client(larkConfig);
|
||||
const client = new this.#lark.Client(larkConfig);
|
||||
this.#client = client;
|
||||
const channel = new VerifiedFeishuChannel({
|
||||
client: this.#client,
|
||||
client,
|
||||
initialText: t('已连接 DeepSeek Harness,正在思考…'),
|
||||
});
|
||||
this.#bridge = new FeishuHarnessBridge({
|
||||
client: this.#client,
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client,
|
||||
channel,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
|
|
@ -226,22 +272,22 @@ export class FeishuRuntime {
|
|||
signal,
|
||||
logger: this.#logger,
|
||||
});
|
||||
this.#bridge = bridge;
|
||||
|
||||
const dispatcher = new this.#lark.EventDispatcher({}).register({
|
||||
'im.message.receive_v1': (event) => {
|
||||
this.#bridge.accept(event);
|
||||
return {};
|
||||
if (isCurrentStart()) void bridge.accept(event);
|
||||
},
|
||||
'im.message.reaction.created_v1': () => ({}),
|
||||
'im.message.reaction.deleted_v1': () => ({}),
|
||||
'im.message.reaction.created_v1': () => undefined,
|
||||
'im.message.reaction.deleted_v1': () => undefined,
|
||||
// Interactive-card button callbacks (only delivered when the app
|
||||
// subscribes card.action.trigger; the number-reply fallback covers
|
||||
// apps that do not).
|
||||
'card.action.trigger': (event) => {
|
||||
if (!isCurrentStart()) return;
|
||||
this.#status.cardActionsReceived += 1;
|
||||
this.#status.lastCardActionAt = new Date().toISOString();
|
||||
if (!this.#consumeCardActionProbe(event)) this.#bridge.onCardAction(event);
|
||||
return {};
|
||||
if (!this.#consumeCardActionProbe(event)) void bridge.onCardAction(event);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -249,37 +295,49 @@ export class FeishuRuntime {
|
|||
let settleError;
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
const onAbort = () => {
|
||||
settleError(abortError());
|
||||
};
|
||||
const settle = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new Error(`Feishu WebSocket handshake timed out after ${this.#connectTimeoutMs}ms`));
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
callback(value);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
settle(
|
||||
reject,
|
||||
new Error(`Feishu WebSocket handshake timed out after ${this.#connectTimeoutMs}ms`),
|
||||
);
|
||||
}, this.#connectTimeoutMs);
|
||||
settleReady = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
settle(resolve);
|
||||
};
|
||||
settleError = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
settle(reject, error);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
// The SDK constructor can throw before Promise.all attaches below.
|
||||
// Keep the abort-driven rejection observed in that path as well.
|
||||
void ready.catch(() => undefined);
|
||||
|
||||
this.#wsClient = new this.#lark.WSClient({
|
||||
const wsClient = new this.#lark.WSClient({
|
||||
...larkConfig,
|
||||
...(this.#wsAgent ? { agent: this.#wsAgent } : {}),
|
||||
loggerLevel: this.#lark.LoggerLevel.info,
|
||||
handshakeTimeoutMs: 15000,
|
||||
handshakeTimeoutMs: this.#connectTimeoutMs,
|
||||
onReady: () => {
|
||||
if (!isCurrentStart()) return;
|
||||
this.#status.feishuLongConnectionState = 'connected';
|
||||
this.#status.ready = true;
|
||||
this.#status.lastError = null;
|
||||
settleReady();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (!isCurrentStart()) return;
|
||||
this.#status.feishuLongConnectionState = 'failed';
|
||||
this.#status.ready = false;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
|
|
@ -287,25 +345,35 @@ export class FeishuRuntime {
|
|||
settleError(error);
|
||||
},
|
||||
onReconnecting: () => {
|
||||
if (!isCurrentStart()) return;
|
||||
this.#status.feishuLongConnectionState = 'reconnecting';
|
||||
this.#status.ready = false;
|
||||
},
|
||||
onReconnected: () => {
|
||||
if (!isCurrentStart()) return;
|
||||
this.#status.feishuLongConnectionState = 'connected';
|
||||
this.#status.ready = true;
|
||||
this.#status.lastError = null;
|
||||
},
|
||||
});
|
||||
await this.#wsClient.start({ eventDispatcher: dispatcher }).catch((error) => {
|
||||
settleError(error);
|
||||
});
|
||||
await ready;
|
||||
this.#wsClient = wsClient;
|
||||
const wsStarted = Promise.resolve()
|
||||
.then(() => wsClient.start({ eventDispatcher: dispatcher }))
|
||||
.catch((error) => {
|
||||
settleError(error);
|
||||
throw error;
|
||||
});
|
||||
await Promise.all([wsStarted, ready]);
|
||||
assertCurrentStart();
|
||||
return this.status;
|
||||
} catch (error) {
|
||||
// stop() owns the terminal idle state for an explicitly aborted start.
|
||||
// In particular, do not let the rejected handshake waiter overwrite it.
|
||||
if (signal.aborted) throw error;
|
||||
this.#status.ready = false;
|
||||
this.#status.feishuLongConnectionState = 'failed';
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
await this.stop({ preserveError: true });
|
||||
await this.#cleanup({ preserveError: true, abortController });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -492,10 +560,30 @@ export class FeishuRuntime {
|
|||
});
|
||||
}
|
||||
|
||||
async stop({ preserveError = false } = {}) {
|
||||
const error = preserveError ? this.#status.lastError : null;
|
||||
stop(options = {}) {
|
||||
if (this.#stopping) return this.#stopping;
|
||||
|
||||
let stopping;
|
||||
stopping = this.#stop(options).finally(() => {
|
||||
if (this.#stopping === stopping) this.#stopping = null;
|
||||
});
|
||||
this.#stopping = stopping;
|
||||
return stopping;
|
||||
}
|
||||
|
||||
async #stop({ preserveError = false } = {}) {
|
||||
const abortController = this.#abortController;
|
||||
this.#abortController = null;
|
||||
if (this.#abortController === abortController) this.#abortController = null;
|
||||
abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
|
||||
|
||||
const starting = this.#starting;
|
||||
if (starting) await starting.catch(() => undefined);
|
||||
return this.#cleanup({ preserveError, abortController });
|
||||
}
|
||||
|
||||
async #cleanup({ preserveError = false, abortController } = {}) {
|
||||
const error = preserveError ? this.#status.lastError : null;
|
||||
if (this.#abortController === abortController) this.#abortController = null;
|
||||
abortController?.abort(new DOMException('Feishu runtime stopped', 'AbortError'));
|
||||
for (const probe of this.#pendingCardActionProbes.values()) {
|
||||
clearTimeout(probe.timeout);
|
||||
|
|
@ -508,14 +596,12 @@ export class FeishuRuntime {
|
|||
}
|
||||
this.#pendingCardActionProbes.clear();
|
||||
this.#status.ready = false;
|
||||
if (this.#wsClient) {
|
||||
this.#wsClient.close({ force: true });
|
||||
this.#wsClient = null;
|
||||
}
|
||||
if (this.#bridge) {
|
||||
await this.#bridge.waitForIdle();
|
||||
this.#bridge = null;
|
||||
}
|
||||
const wsClient = this.#wsClient;
|
||||
this.#wsClient = null;
|
||||
wsClient?.close({ force: true });
|
||||
const bridge = this.#bridge;
|
||||
this.#bridge = null;
|
||||
if (bridge) await bridge.waitForIdle();
|
||||
this.#client = null;
|
||||
this.#status.feishuLongConnectionState = preserveError ? 'failed' : 'idle';
|
||||
this.#status.lastError = error;
|
||||
|
|
|
|||
|
|
@ -223,6 +223,11 @@ function workspaceSessions(workspace, archivedSessionIds, sessionList) {
|
|||
origin: summary?.origin === 'subagent' ? 'subagent' : null,
|
||||
summaryAvailable: summary !== undefined,
|
||||
};
|
||||
const lastSeq = summary?.projections?.asOfSeq;
|
||||
// This is the projection's durable lower bound, not necessarily the live
|
||||
// log tail for a cold session. Harness uses -1 as the legitimate bound
|
||||
// for a session with no projected events yet.
|
||||
if (Number.isSafeInteger(lastSeq) && lastSeq >= -1) session.lastSeq = lastSeq;
|
||||
const time = sessionTimeMs(summary);
|
||||
if (time !== null) session.time = time;
|
||||
return session;
|
||||
|
|
|
|||
|
|
@ -134,9 +134,20 @@ export default {
|
|||
'当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。':
|
||||
'The current task is still running. Stop it or wait for it to finish before starting a new session.',
|
||||
'请输入补充指令后再提交。': 'Enter an instruction before submitting.',
|
||||
'操作过于频繁,请稍后再试。': 'Too many card actions are pending. Please try again shortly.',
|
||||
'卡片操作失败,请稍后重试。': 'The card action failed. Please try again later.',
|
||||
'请先选择至少一个会话。': 'Select at least one session first.',
|
||||
'已批量关注 {count} 个会话。': 'Now watching {count} sessions.',
|
||||
'已批量关注 {count} 个会话,另有 {failed} 个未成功。':
|
||||
'Now watching {count} sessions; {failed} could not be processed.',
|
||||
'已关注(或已达关注上限)。': 'Sessions are already watched, or the watch limit has been reached.',
|
||||
'已取消关注 {count} 个会话。': 'Stopped watching {count} sessions.',
|
||||
'已取消关注 {count} 个会话,另有 {failed} 个未成功。':
|
||||
'Stopped watching {count} sessions; {failed} could not be processed.',
|
||||
'所选会话均未处理成功,请稍后重试。':
|
||||
'None of the selected sessions could be processed. Please try again later.',
|
||||
'所选会话已在关注列表中。': 'The selected sessions are already being watched.',
|
||||
'所选会话已不在关注列表中。': 'The selected sessions are no longer in the watch list.',
|
||||
'未取消任何关注。': 'No watches were removed.',
|
||||
'当前没有绑定的会话,请先从会话列表选择。':
|
||||
'No session is currently bound. Select one from the session list first.',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const MODELS_COMMAND = /^\/models(?=$|\s)/i;
|
|||
const MODEL_USAGE = '用法:/model <序号> 或 /model <provider>/<model>';
|
||||
const MODELS_USAGE = '用法:/models(不带参数)';
|
||||
const SESSION_BINDING_CHANGED = 'session-binding-changed';
|
||||
const MODEL_SELECTION_MISMATCH = 'model-selection-mismatch';
|
||||
const UNSAFE_DISPLAY_TEXT_GLOBAL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
|
||||
|
||||
function commandResult(message) {
|
||||
|
|
@ -78,6 +79,32 @@ function modelId(provider, model) {
|
|||
return `${provider}/${model}`;
|
||||
}
|
||||
|
||||
function sameModel(left, right) {
|
||||
return left?.provider === right?.provider && left?.model === right?.model;
|
||||
}
|
||||
|
||||
function selectionMismatch(expected, actual, source) {
|
||||
const error = new Error(`Harness ${source} did not confirm the selected model`);
|
||||
error.code = MODEL_SELECTION_MISMATCH;
|
||||
error.expected = expected;
|
||||
error.actual = actual;
|
||||
error.source = source;
|
||||
return error;
|
||||
}
|
||||
|
||||
function sessionBindingChanged() {
|
||||
const error = new Error('Conversation binding changed during model selection');
|
||||
error.code = SESSION_BINDING_CHANGED;
|
||||
return error;
|
||||
}
|
||||
|
||||
function assertSessionBinding(state, key, expectedSessionId) {
|
||||
const currentSessionId = typeof state?.sessionFor === 'function'
|
||||
? state.sessionFor(key)
|
||||
: null;
|
||||
if (currentSessionId !== expectedSessionId) throw sessionBindingChanged();
|
||||
}
|
||||
|
||||
function matchingModel(catalog, requested) {
|
||||
for (const group of catalog.groups) {
|
||||
for (const model of group.models) {
|
||||
|
|
@ -181,6 +208,23 @@ function modelErrorMessage(error, action) {
|
|||
if (code === SESSION_BINDING_CHANGED) {
|
||||
return t('当前聊天绑定的会话已发生变化,请重试。');
|
||||
}
|
||||
if (code === MODEL_SELECTION_MISMATCH) {
|
||||
const expected = error?.expected;
|
||||
const actual = error?.actual;
|
||||
const lines = [t('模型切换失败,请稍后重试。')];
|
||||
if (expected?.provider && expected?.model) {
|
||||
lines.push('', `requested: ${safeDisplayText(modelId(expected.provider, expected.model))}`);
|
||||
}
|
||||
if (actual?.provider && actual?.model) {
|
||||
const label = error?.source === 'models.current'
|
||||
? t('当前模型:')
|
||||
: 'selectModel.selected:';
|
||||
lines.push(`${label} ${safeDisplayText(modelId(actual.provider, actual.model))}`);
|
||||
} else {
|
||||
lines.push(`${error?.source ?? 'Harness'}: unconfirmed`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
if (code === 'cancelled' || error?.name === 'AbortError') {
|
||||
return action === 'list' ? t('获取模型列表已取消。') : t('模型切换已取消。');
|
||||
}
|
||||
|
|
@ -230,6 +274,21 @@ async function sessionCatalog(session, options) {
|
|||
return normalizeCatalog(await session.models(options), { requireCurrent: true });
|
||||
}
|
||||
|
||||
async function selectAndVerifyModel(session, selection, options) {
|
||||
if (typeof session?.selectModel !== 'function') {
|
||||
throw new TypeError('Harness session does not support model selection');
|
||||
}
|
||||
const selected = (await session.selectModel(selection, options))?.selected;
|
||||
if (!sameModel(selected, selection)) {
|
||||
throw selectionMismatch(selection, selected, 'selectModel.selected');
|
||||
}
|
||||
const current = (await sessionCatalog(session, options)).current;
|
||||
if (!sameModel(current, selection)) {
|
||||
throw selectionMismatch(selection, current, 'models.current');
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isModelsCommand(command) {
|
||||
return MODELS_COMMAND.test(command);
|
||||
}
|
||||
|
|
@ -312,11 +371,10 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
|
|||
].join('\n'));
|
||||
}
|
||||
|
||||
let applied;
|
||||
if (bound) {
|
||||
if (typeof bound.session.selectModel !== 'function') {
|
||||
throw new TypeError('Harness session does not support model selection');
|
||||
}
|
||||
await bound.session.selectModel(selection, requestOptions);
|
||||
applied = await selectAndVerifyModel(bound.session, selection, requestOptions);
|
||||
assertSessionBinding(state, key, bound.sessionId);
|
||||
} else {
|
||||
if (typeof harness?.createSession !== 'function'
|
||||
|| typeof harness?.workspaceSession !== 'function'
|
||||
|
|
@ -329,15 +387,10 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
|
|||
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);
|
||||
applied = await selectAndVerifyModel(session, 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;
|
||||
throw sessionBindingChanged();
|
||||
}
|
||||
if (await state.setSession(key, sessionId) === false) {
|
||||
const stale = new Error('Workspace changed while binding the new session');
|
||||
|
|
@ -348,7 +401,7 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
|
|||
return commandResult(t(`模型已切换为:
|
||||
{model}
|
||||
|
||||
后续消息将使用该模型。`, { model: modelId(selection.provider, selection.model) }));
|
||||
后续消息将使用该模型。`, { model: modelId(applied.provider, applied.model) }));
|
||||
});
|
||||
} catch (error) {
|
||||
return commandResult(modelErrorMessage(error, 'select'));
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ async function selectedWorkspacePath(value) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function workspacePathSnapshot(harness) {
|
||||
const listed = await harness.listWorkspaces();
|
||||
export async function workspacePathSnapshot(harness, options = {}) {
|
||||
const listed = await harness.listWorkspaces(options);
|
||||
const currentValue = typeof harness?.currentWorkspace === 'function'
|
||||
? harness.currentWorkspace()
|
||||
: null;
|
||||
|
|
@ -155,7 +155,7 @@ async function runWorkspaceListCommand(match, harness) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function resolveSessionListWorkspace(selector, harness) {
|
||||
export async function resolveSessionListWorkspace(selector, harness, options = {}) {
|
||||
if (!selector) {
|
||||
if (typeof harness?.currentWorkspace !== 'function') {
|
||||
return { error: t('当前机器人没有可用的工作区。') };
|
||||
|
|
@ -169,7 +169,7 @@ export async function resolveSessionListWorkspace(selector, harness) {
|
|||
if (typeof harness?.listWorkspaces !== 'function') {
|
||||
return { error: t('当前机器人暂不支持按序号选择工作区。') };
|
||||
}
|
||||
const { paths } = await workspacePathSnapshot(harness);
|
||||
const { paths } = await workspacePathSnapshot(harness, options);
|
||||
const position = Number(selector);
|
||||
if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
|
||||
return { error: t('工作区序号不存在,请先执行 /workspacelist。') };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue