@xmanrui/dsh-im 0.4.0 → 0.6.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.
Files changed (64) hide show
  1. package/README.md +36 -0
  2. package/lib/client.js +709 -285
  3. package/lib/index.js +114 -110
  4. package/package.json +1 -1
  5. package/plugin-src/client/channels/dingtalk/api.js +2 -0
  6. package/plugin-src/client/channels/dingtalk/index.js +67 -14
  7. package/plugin-src/client/channels/feishu/api.js +2 -0
  8. package/plugin-src/client/channels/feishu/index.js +70 -22
  9. package/plugin-src/client/channels/qq/api.js +2 -0
  10. package/plugin-src/client/channels/qq/index.js +45 -8
  11. package/plugin-src/client/channels/shared/token-api.js +2 -0
  12. package/plugin-src/client/channels/shared/token-channel.js +34 -8
  13. package/plugin-src/client/channels/wecom/api.js +2 -0
  14. package/plugin-src/client/channels/wecom/index.js +45 -8
  15. package/plugin-src/client/channels/weixin/api.js +2 -0
  16. package/plugin-src/client/channels/weixin/index.js +68 -8
  17. package/plugin-src/client/channels/whatsapp/api.js +2 -0
  18. package/plugin-src/client/channels/whatsapp/index.js +27 -5
  19. package/plugin-src/client/i18n.js +13 -0
  20. package/plugin-src/client/styles.js +13 -0
  21. package/plugin-src/client/workspace-editor.js +96 -0
  22. package/plugin-src/client/workspace-snapshot-fence.js +28 -0
  23. package/plugin-src/host/channels/dingtalk/production.mjs +34 -11
  24. package/plugin-src/host/channels/dingtalk/rpc.mjs +17 -2
  25. package/plugin-src/host/channels/feishu/production.mjs +42 -5
  26. package/plugin-src/host/channels/feishu/rpc.mjs +17 -2
  27. package/plugin-src/host/channels/qq/production.mjs +48 -22
  28. package/plugin-src/host/channels/qq/rpc.mjs +16 -2
  29. package/plugin-src/host/channels/shared/production.mjs +48 -22
  30. package/plugin-src/host/channels/shared/rpc.mjs +15 -0
  31. package/plugin-src/host/channels/shared/workspace-rpc.mjs +25 -0
  32. package/plugin-src/host/channels/slack/production.mjs +48 -23
  33. package/plugin-src/host/channels/slack/rpc.mjs +16 -0
  34. package/plugin-src/host/channels/wecom/production.mjs +49 -23
  35. package/plugin-src/host/channels/wecom/rpc.mjs +16 -2
  36. package/plugin-src/host/channels/weixin/production.mjs +31 -11
  37. package/plugin-src/host/channels/weixin/rpc.mjs +21 -2
  38. package/plugin-src/host/channels/whatsapp/production.mjs +49 -23
  39. package/plugin-src/host/channels/whatsapp/rpc.mjs +16 -2
  40. package/src/channels/dingtalk/dingtalk-bridge.mjs +25 -11
  41. package/src/channels/dingtalk/dingtalk-controller.mjs +6 -1
  42. package/src/channels/dingtalk/harness-client.mjs +17 -3
  43. package/src/channels/dingtalk/state-store.mjs +5 -0
  44. package/src/channels/discord/discord-api.mjs +1 -1
  45. package/src/channels/feishu/bridge.mjs +39 -16
  46. package/src/channels/feishu/harness-client.mjs +19 -5
  47. package/src/channels/feishu/state-store.mjs +5 -0
  48. package/src/channels/qq/qq-bridge.mjs +28 -15
  49. package/src/channels/qq/qq-controller.mjs +8 -1
  50. package/src/channels/qq/state-store.mjs +5 -0
  51. package/src/channels/shared/bot-workspace-store.mjs +618 -0
  52. package/src/channels/shared/conversation-state-store.mjs +5 -0
  53. package/src/channels/shared/text-harness-bridge.mjs +26 -13
  54. package/src/channels/shared/workspace-command.mjs +115 -0
  55. package/src/channels/shared/workspace-session.mjs +44 -0
  56. package/src/channels/wecom/state-store.mjs +5 -0
  57. package/src/channels/wecom/wecom-bridge.mjs +25 -13
  58. package/src/channels/wecom/wecom-controller.mjs +8 -1
  59. package/src/channels/weixin/harness-client.mjs +19 -5
  60. package/src/channels/weixin/state-store.mjs +5 -0
  61. package/src/channels/weixin/weixin-api.mjs +1 -1
  62. package/src/channels/weixin/weixin-bridge.mjs +19 -6
  63. package/src/channels/weixin/weixin-controller.mjs +6 -1
  64. package/src/channels/whatsapp/whatsapp-controller.mjs +8 -1
@@ -8,6 +8,12 @@ import { WhatsappStateStore } from '../../../../src/channels/whatsapp/state-stor
8
8
  import { WhatsappController } from '../../../../src/channels/whatsapp/whatsapp-controller.mjs';
9
9
  import { WhatsappRuntime } from '../../../../src/channels/whatsapp/whatsapp-runtime.mjs';
10
10
  import { createWhatsappWebSession } from '../../../../src/channels/whatsapp/whatsapp-web-session.mjs';
11
+ import {
12
+ BotWorkspaceStore,
13
+ createBotWorkspaceScope,
14
+ createWorkspaceAwareController,
15
+ observeBotWorkspaceRemovals,
16
+ } from '../../../../src/channels/shared/bot-workspace-store.mjs';
11
17
  import { createTokenConnectionSupervisor } from '../shared/connection-supervisor.mjs';
12
18
 
13
19
  const AUTH_DIRECTORY_PATTERN = /^[a-f0-9-]{36}$/;
@@ -32,6 +38,7 @@ function pluginPaths(config) {
32
38
  return {
33
39
  config: resolve(config.configPath ?? join(root, 'config.json')),
34
40
  bots: resolve(config.botsDir ?? join(root, 'bots')),
41
+ workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
35
42
  authPath,
36
43
  };
37
44
  }
@@ -49,6 +56,16 @@ export async function createProductionController(ctx, config = {}, internals = {
49
56
  const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
50
57
  const paths = pluginPaths(config);
51
58
  const configStore = await new ConfigStore(paths.config).load();
59
+ const defaultWorkspace = resolve(config.workspace ?? process.cwd());
60
+ const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
61
+ const workspaces = internals.workspaces
62
+ ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
63
+ const configuredBots = configStore.list();
64
+ await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
65
+ await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
66
+ const observedConfigStore = typeof configStore.remove === 'function'
67
+ ? observeBotWorkspaceRemovals(configStore, { workspaces })
68
+ : configStore;
52
69
  const stateStores = new Map();
53
70
  const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
54
71
  const stateFor = async (botId) => {
@@ -61,31 +78,36 @@ export async function createProductionController(ctx, config = {}, internals = {
61
78
  };
62
79
  const harness = new Harness({
63
80
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
64
- workspace: resolve(config.workspace ?? process.cwd()),
81
+ workspace: defaultWorkspace,
65
82
  agentPreset: config.agentPreset ?? 'standard',
66
83
  autostart: false,
67
84
  dshBin: config.dshBin ?? 'dsh',
68
85
  });
69
- const controller = new Controller({
70
- configStore,
86
+ const coreController = new Controller({
87
+ configStore: observedConfigStore,
71
88
  authPath: paths.authPath,
72
89
  createSession,
73
90
  logger,
74
- createRuntime: async ({ botId, config: botConfig, authDir }) => new Runtime({
75
- config: botConfig,
76
- authDir,
77
- harness,
78
- state: await stateFor(botId),
79
- replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
80
- connectTimeoutMs: config.connectTimeoutMs ?? 30_000,
81
- createSession,
82
- logger: {
83
- error: (...args) => logger.error?.(`[${botId}]`, ...args),
84
- warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
85
- info: (...args) => logger.info?.(`[${botId}]`, ...args),
86
- debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
87
- },
88
- }),
91
+ createRuntime: async ({ botId, config: botConfig, authDir }) => {
92
+ const state = await stateFor(botId);
93
+ await workspaces.ensure(botId);
94
+ const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
95
+ return new Runtime({
96
+ config: botConfig,
97
+ authDir,
98
+ harness: workspaceScope.harness,
99
+ state: workspaceScope.state,
100
+ replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
101
+ connectTimeoutMs: config.connectTimeoutMs ?? 30_000,
102
+ createSession,
103
+ logger: {
104
+ error: (...args) => logger.error?.(`[${botId}]`, ...args),
105
+ warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
106
+ info: (...args) => logger.info?.(`[${botId}]`, ...args),
107
+ debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
108
+ },
109
+ });
110
+ },
89
111
  deleteAuth: (authDirectory) => rm(paths.authPath(authDirectory), {
90
112
  recursive: true,
91
113
  force: true,
@@ -93,14 +115,18 @@ export async function createProductionController(ctx, config = {}, internals = {
93
115
  deleteState: async ({ botId }) => {
94
116
  const state = stateStores.get(botId);
95
117
  stateStores.delete(botId);
96
- if (state && typeof state.remove === 'function') return state.remove();
97
- try {
98
- await unlink(statePath(botId));
99
- } catch (error) {
100
- if (error?.code !== 'ENOENT') throw error;
118
+ if (state && typeof state.remove === 'function') {
119
+ await state.remove();
120
+ } else {
121
+ try {
122
+ await unlink(statePath(botId));
123
+ } catch (error) {
124
+ if (error?.code !== 'ENOENT') throw error;
125
+ }
101
126
  }
102
127
  },
103
128
  });
129
+ const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
104
130
  const supervisor = createSupervisor({
105
131
  channel: 'whatsapp',
106
132
  controller,
@@ -1,6 +1,7 @@
1
1
  import QRCode from 'qrcode';
2
2
 
3
3
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
4
+ import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
4
5
 
5
6
  export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
6
7
  export const WHATSAPP_ENDPOINTS = Object.freeze({
@@ -10,6 +11,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
10
11
  cancelProvisioning: 'provision.cancel',
11
12
  reconnectBot: 'bot.reconnect',
12
13
  deleteBot: 'bot.delete',
14
+ setWorkspace: SET_WORKSPACE_ENDPOINT,
13
15
  });
14
16
  export const WHATSAPP_RPC_ENDPOINTS = Object.freeze(Object.values(WHATSAPP_ENDPOINTS));
15
17
 
@@ -44,6 +46,10 @@ function payloadFailure(endpoint, payload) {
44
46
  return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId)
45
47
  && payload.confirm === true ? null : 'bot.delete requires a botId and confirm=true.';
46
48
  }
49
+ if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
50
+ return validWorkspacePayload(payload)
51
+ ? null : '请输入工作区绝对路径。';
52
+ }
47
53
  return 'Unknown WhatsApp endpoint.';
48
54
  }
49
55
 
@@ -114,16 +120,24 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
114
120
  value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
115
121
  } else if (endpoint === WHATSAPP_ENDPOINTS.reconnectBot) {
116
122
  value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
123
+ } else if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
124
+ if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
125
+ value = await publicStatus(
126
+ await controller.updateWorkspace(payload.botId, payload.workspace),
127
+ cachedEncode,
128
+ );
117
129
  } else {
118
130
  value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
119
131
  }
120
132
  return signal?.aborted
121
133
  ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
122
134
  : { ok: true, value };
123
- } catch {
135
+ } catch (error) {
136
+ const workspaceError = publicWorkspaceError(error);
124
137
  return signal?.aborted
125
138
  ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
126
- : { ok: false, error: { code: 'whatsapp-operation-failed', message: 'WhatsApp 操作失败,请稍后重试。' } };
139
+ : { ok: false, error: workspaceError
140
+ ?? { code: 'whatsapp-operation-failed', message: 'WhatsApp 操作失败,请稍后重试。' } };
127
141
  }
128
142
  };
129
143
  }
@@ -3,6 +3,8 @@ import {
3
3
  splitDingtalkText,
4
4
  } from './dingtalk-api.mjs';
5
5
  import { createDingTalkCardStream } from './dingtalk-card-stream.mjs';
6
+ import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
7
+ import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
6
8
 
7
9
  const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
8
10
  const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
@@ -12,6 +14,8 @@ const HELP_TEXT = [
12
14
  '',
13
15
  '直接发送文字即可继续当前会话。',
14
16
  '/new 开启一个全新会话',
17
+ '/workspace 工作区绝对路径 切换工作区',
18
+ '/workspacelist 列出工作区绝对路径',
15
19
  '/status 检查连接状态',
16
20
  '/help 显示本帮助',
17
21
  ].join('\n');
@@ -213,12 +217,14 @@ export class DingtalkHarnessBridge {
213
217
  await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
214
218
  return;
215
219
  }
216
-
217
- let sessionId = this.#state.sessionFor(key);
218
- if (!sessionId || !(await this.#harness.sessionExists(sessionId, { signal: this.#signal }))) {
219
- sessionId = await this.#harness.createSession({ signal: this.#signal });
220
- await this.#state.setSession(key, sessionId);
220
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
221
+ if (workspaceCommand) {
222
+ for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
223
+ await this.#send(sessionWebhook, reply);
224
+ }
225
+ return;
221
226
  }
227
+
222
228
  if (typeof this.#api.createAiCard === 'function'
223
229
  && typeof this.#api.updateAiCard === 'function'
224
230
  && typeof this.#api.finishAiCard === 'function') {
@@ -232,12 +238,20 @@ export class DingtalkHarnessBridge {
232
238
  });
233
239
  cardStarted = await cardStream.start(CARD_INITIAL_TEXT);
234
240
  }
235
- const answer = await this.#harness.ask(sessionId, text, {
236
- timeoutMs: this.#replyTimeoutMs,
237
- signal: this.#signal,
238
- onUpdate: cardStarted
239
- ? (update) => cardStream.push(progressText(update))
240
- : undefined,
241
+ const { answer } = await askInWorkspaceSession({
242
+ harness: this.#harness,
243
+ state: this.#state,
244
+ key,
245
+ text,
246
+ createOptions: { signal: this.#signal },
247
+ existsOptions: { signal: this.#signal },
248
+ askOptions: {
249
+ timeoutMs: this.#replyTimeoutMs,
250
+ signal: this.#signal,
251
+ onUpdate: cardStarted
252
+ ? (update) => cardStream.push(progressText(update))
253
+ : undefined,
254
+ },
241
255
  });
242
256
  const streamed = cardStarted && await cardStream.finish(answer);
243
257
  if (!streamed) await this.#send(sessionWebhook, answer);
@@ -610,7 +610,12 @@ export class DingtalkController {
610
610
  await this.#stopRuntime(identity.botId);
611
611
  if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
612
612
  else if (this.#configStore.get(identity.botId)) {
613
- await this.#configStore.remove(identity.botId).catch(() => undefined);
613
+ const removed = await this.#configStore.remove(identity.botId).catch(() => null);
614
+ if (removed) {
615
+ await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
616
+ this.#logger.warn?.('[dsh-dingtalk] failed to clean up cancelled bot state:', cleanupError);
617
+ });
618
+ }
614
619
  }
615
620
  await this.#restoreCredential(identity.secretRef, previousSecret);
616
621
  if (previousConfig && cleanString(previousSecret?.value)) {
@@ -1,5 +1,13 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { isAbsolute } from 'node:path';
4
+
5
+ function workspacePaths(value) {
6
+ if (!Array.isArray(value?.items)) return [];
7
+ return value.items.flatMap((item) => (
8
+ typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
9
+ ));
10
+ }
3
11
 
4
12
  function sleep(ms, signal) {
5
13
  return new Promise((resolve, reject) => {
@@ -216,11 +224,17 @@ export class HarnessClient {
216
224
  throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
217
225
  }
218
226
 
227
+ async listWorkspaces(options = {}) {
228
+ await this.ensureRunning(options);
229
+ return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
230
+ }
231
+
219
232
  async workspaceId(options = {}) {
220
- const { items } = await this.rpc('workspace.list', {}, 30_000, options);
221
- const existing = items.find((item) => item.path === this.#workspace);
233
+ const { workspace = this.#workspace, ...rpcOptions } = options;
234
+ const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
235
+ const existing = items.find((item) => item.path === workspace);
222
236
  if (existing) return existing.workspaceId;
223
- const created = await this.rpc('workspace.create', { path: this.#workspace }, 30_000, options);
237
+ const created = await this.rpc('workspace.create', { path: workspace }, 30_000, rpcOptions);
224
238
  return created.workspace.workspaceId;
225
239
  }
226
240
 
@@ -117,6 +117,11 @@ export class DingtalkStateStore {
117
117
  await this.#persist();
118
118
  }
119
119
 
120
+ async clearSessions() {
121
+ this.#state.sessions = {};
122
+ await this.#persist();
123
+ }
124
+
120
125
  hasSeen(messageId) {
121
126
  const id = nonEmptyString(messageId);
122
127
  return Boolean(id && this.#state.seenMessageIds.includes(id));
@@ -108,7 +108,7 @@ export class DiscordApi {
108
108
  headers: {
109
109
  authorization: `Bot ${this.#token}`,
110
110
  'content-type': 'application/json',
111
- 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.4.0)',
111
+ 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.6.0)',
112
112
  },
113
113
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
114
114
  signal: requestSignal(signal, timeoutMs),
@@ -5,12 +5,16 @@ import {
5
5
  isBotSender,
6
6
  splitText,
7
7
  } from './message-utils.mjs';
8
+ import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
9
+ import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
8
10
 
9
11
  const HELP_TEXT = [
10
12
  '北汇星河 AIOS 已连接 DeepSeek Harness。',
11
13
  '',
12
14
  '直接发送问题即可继续当前会话。',
13
15
  '/new 开启一个全新会话',
16
+ '/workspace 工作区绝对路径 切换工作区',
17
+ '/workspacelist 列出工作区绝对路径',
14
18
  '/status 检查连接状态',
15
19
  '/help 显示本帮助',
16
20
  ].join('\n');
@@ -106,25 +110,32 @@ export class FeishuHarnessBridge {
106
110
  await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
107
111
  return;
108
112
  }
109
-
110
- let sessionId = this.#state.sessionFor(key);
111
- if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
112
- sessionId = await this.#harness.createSession();
113
- await this.#state.setSession(key, sessionId);
113
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
114
+ if (workspaceCommand) {
115
+ for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
116
+ await this.#send(event.message.chat_id, reply);
117
+ }
118
+ return;
114
119
  }
115
120
 
116
- console.info(`[bridge] processing ${event.message.chat_type} message ${messageId} in ${sessionId}`);
117
- await this.#answerWithStream(event, sessionId, text);
121
+ console.info(`[bridge] processing ${event.message.chat_type} message ${messageId}`);
122
+ await this.#answerWithStream(event, key, text);
118
123
  this.#status.messagesReplied += 1;
119
124
  this.#status.lastReplyAt = new Date().toISOString();
120
125
  this.#status.lastError = null;
121
126
  }
122
127
 
123
- async #answerWithStream(event, sessionId, text) {
128
+ async #answerWithStream(event, key, text) {
124
129
  const chatId = event.message.chat_id;
125
130
  const messageId = event.message.message_id;
126
131
  if (!this.#channel?.stream) {
127
- const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs });
132
+ const { answer } = await askInWorkspaceSession({
133
+ harness: this.#harness,
134
+ state: this.#state,
135
+ key,
136
+ text,
137
+ askOptions: { timeoutMs: this.#replyTimeoutMs },
138
+ });
128
139
  for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
129
140
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
130
141
  return;
@@ -136,13 +147,19 @@ export class FeishuHarnessBridge {
136
147
  await this.#channel.stream(chatId, {
137
148
  markdown: async (controller) => {
138
149
  promptStarted = true;
139
- completedAnswer = await this.#harness.ask(sessionId, text, {
140
- timeoutMs: this.#replyTimeoutMs,
141
- onUpdate: async (update) => {
142
- await controller.setContent(this.#progressText(update));
143
- this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
150
+ ({ answer: completedAnswer } = await askInWorkspaceSession({
151
+ harness: this.#harness,
152
+ state: this.#state,
153
+ key,
154
+ text,
155
+ askOptions: {
156
+ timeoutMs: this.#replyTimeoutMs,
157
+ onUpdate: async (update) => {
158
+ await controller.setContent(this.#progressText(update));
159
+ this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
160
+ },
144
161
  },
145
- });
162
+ }));
146
163
  await controller.setContent(completedAnswer);
147
164
  },
148
165
  }, { replyTo: messageId });
@@ -158,7 +175,13 @@ export class FeishuHarnessBridge {
158
175
  if (promptStarted) throw error;
159
176
 
160
177
  console.warn('[bridge] native Feishu stream unavailable; using text fallback:', error.message);
161
- const answer = await this.#harness.ask(sessionId, text, { timeoutMs: this.#replyTimeoutMs });
178
+ const { answer } = await askInWorkspaceSession({
179
+ harness: this.#harness,
180
+ state: this.#state,
181
+ key,
182
+ text,
183
+ askOptions: { timeoutMs: this.#replyTimeoutMs },
184
+ });
162
185
  for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
163
186
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
164
187
  }
@@ -1,8 +1,16 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { isAbsolute } from 'node:path';
3
4
 
4
5
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
6
 
7
+ function workspacePaths(value) {
8
+ if (!Array.isArray(value?.items)) return [];
9
+ return value.items.flatMap((item) => (
10
+ typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : []
11
+ ));
12
+ }
13
+
6
14
  function messageText(event) {
7
15
  return (event?.data?.message?.content ?? [])
8
16
  .filter((part) => part.type === 'text' && typeof part.text === 'string')
@@ -197,17 +205,23 @@ export class HarnessClient {
197
205
  throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
198
206
  }
199
207
 
200
- async workspaceId() {
208
+ async listWorkspaces(options = {}) {
209
+ await this.ensureRunning();
210
+ return workspacePaths(await this.rpc('workspace.list', {}, 30000, options));
211
+ }
212
+
213
+ async workspaceId(options = {}) {
214
+ const workspace = options.workspace ?? this.#workspace;
201
215
  const { items } = await this.rpc('workspace.list', {});
202
- const existing = items.find((item) => item.path === this.#workspace);
216
+ const existing = items.find((item) => item.path === workspace);
203
217
  if (existing) return existing.workspaceId;
204
- const created = await this.rpc('workspace.create', { path: this.#workspace });
218
+ const created = await this.rpc('workspace.create', { path: workspace });
205
219
  return created.workspace.workspaceId;
206
220
  }
207
221
 
208
- async createSession() {
222
+ async createSession(options = {}) {
209
223
  await this.ensureRunning();
210
- const workspaceId = await this.workspaceId();
224
+ const workspaceId = await this.workspaceId(options);
211
225
  const created = await this.rpc('session.create', {
212
226
  workspaceId,
213
227
  agentPreset: this.#agentPreset,
@@ -41,6 +41,11 @@ export class StateStore {
41
41
  await this.#persist();
42
42
  }
43
43
 
44
+ async clearSessions() {
45
+ this.#state.sessions = {};
46
+ await this.#persist();
47
+ }
48
+
44
49
  hasSeen(messageId) {
45
50
  return this.#state.seenMessageIds.includes(messageId);
46
51
  }
@@ -1,8 +1,13 @@
1
+ import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
2
+ import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
3
+
1
4
  const HELP_TEXT = [
2
5
  'QQ 机器人已连接 DeepSeek Harness。',
3
6
  '',
4
7
  '直接发送文字即可继续当前会话。',
5
8
  '/new 开启一个全新会话',
9
+ '/workspace 工作区绝对路径 切换工作区',
10
+ '/workspacelist 列出工作区绝对路径',
6
11
  '/status 检查连接状态',
7
12
  '/help 显示本帮助',
8
13
  ].join('\n');
@@ -121,11 +126,13 @@ export class QqHarnessBridge {
121
126
  await this.#state.markSeen(messageId);
122
127
  return;
123
128
  }
124
-
125
- let sessionId = this.#state.sessionFor(key);
126
- if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
127
- sessionId = await this.#harness.createSession();
128
- await this.#state.setSession(key, sessionId);
129
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
130
+ if (workspaceCommand) {
131
+ for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
132
+ await this.#bot.sendText(target, reply);
133
+ }
134
+ await this.#state.markSeen(messageId);
135
+ return;
129
136
  }
130
137
 
131
138
  let stream = null;
@@ -137,16 +144,22 @@ export class QqHarnessBridge {
137
144
  this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', error);
138
145
  }
139
146
  }
140
- const answer = await this.#harness.ask(sessionId, text, {
141
- timeoutMs: this.#replyTimeoutMs,
142
- onUpdate: stream ? async (update) => {
143
- const progress = update.type === 'text'
144
- ? update.text
145
- : update.type === 'tool'
146
- ? `正在使用${update.name}…`
147
- : update.text;
148
- if (progress) await stream.update(progress);
149
- } : undefined,
147
+ const { answer } = await askInWorkspaceSession({
148
+ harness: this.#harness,
149
+ state: this.#state,
150
+ key,
151
+ text,
152
+ askOptions: {
153
+ timeoutMs: this.#replyTimeoutMs,
154
+ onUpdate: stream ? async (update) => {
155
+ const progress = update.type === 'text'
156
+ ? update.text
157
+ : update.type === 'tool'
158
+ ? `正在使用${update.name}…`
159
+ : update.text;
160
+ if (progress) await stream.update(progress);
161
+ } : undefined,
162
+ },
150
163
  });
151
164
  if (stream) {
152
165
  try {
@@ -388,7 +388,14 @@ export class QqController {
388
388
  if (record.controller.signal.aborted) {
389
389
  await this.#stopRuntime(identity.botId);
390
390
  if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
391
- else await this.#configStore.remove(identity.botId).catch(() => undefined);
391
+ else {
392
+ const removed = await this.#configStore.remove(identity.botId).catch(() => null);
393
+ if (removed) {
394
+ await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
395
+ this.#logger.warn?.('[dsh-im:qq] cancelled bot state cleanup failed:', cleanupError);
396
+ });
397
+ }
398
+ }
392
399
  await this.#restoreCredential(identity.secretRef, previousSecret);
393
400
  throw error;
394
401
  }
@@ -56,6 +56,11 @@ export class QqStateStore {
56
56
  await this.#persist();
57
57
  }
58
58
 
59
+ async clearSessions() {
60
+ this.#state.sessions = {};
61
+ await this.#persist();
62
+ }
63
+
59
64
  hasSeen(messageId) {
60
65
  return this.#state.seenMessageIds.includes(messageId);
61
66
  }