dsh-qqchat 0.2.3 → 0.2.4

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/README.en.md CHANGED
@@ -220,7 +220,7 @@ QQ Sessions use DSH's native Agent Preset mechanism. The preset configured for t
220
220
  ## Notes
221
221
 
222
222
  - An official QQ Bot is required; personal QQ account login is not supported.
223
- - `0.2.1` is still being refined; test with a dedicated Bot and group first.
223
+ - `0.2.2` is still being refined; test with a dedicated Bot and group first.
224
224
  - Rich media, full QQ emoji handling and some complex message formats may require compatibility mode.
225
225
  - Uninstalling the plugin does not automatically delete saved chat or memory data.
226
226
 
package/README.md CHANGED
@@ -202,7 +202,7 @@ QQ Session 使用 DSH 原生的 Agent Preset 机制。插件配置中的 preset
202
202
  ## 注意事项
203
203
 
204
204
  - 需要 QQ 官方 Bot,不支持个人 QQ 账号登录。
205
- - `0.2.1` 仍在持续完善,建议先在测试 Bot 和测试群中使用。
205
+ - `0.2.2` 仍在持续完善,建议先在测试 Bot 和测试群中使用。
206
206
  - 富媒体、完整 QQ 表情和部分复杂消息格式可能需要使用兼容模式。
207
207
  - 卸载插件不会自动删除已经保存的聊天和记忆数据。
208
208
 
@@ -1,4 +1,4 @@
1
- import { installStyles, setQQRpc } from './shared.cjs'
1
+ import { installQQSettingsIcon, installStyles, setQQRpc } from './shared.cjs'
2
2
  import type { Ctx } from './shared.cjs'
3
3
  import { QQSettings } from './settings.cjs'
4
4
  import { QQSessionUtility, QQTranscriptNode, createQQMessageDefinition } from './workspace.cjs'
@@ -7,6 +7,7 @@ exports.inject = ['slots', 'connection', 'conversationEvents'] as const
7
7
  exports.apply = function apply(ctx: Ctx) {
8
8
  setQQRpc(ctx.connection.rpc)
9
9
  ctx.effect(installStyles, 'dsh-qqchat: client styles')
10
+ ctx.effect(installQQSettingsIcon, 'dsh-qqchat: QQ settings icon')
10
11
  ctx.conversationEvents.register(createQQMessageDefinition())
11
12
 
12
13
  ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'qqchat', order: 35, label: () => 'QQ Chat', inject: () => ({ rpc: ctx.connection.rpc }) }, QQSettings))
@@ -55,6 +55,47 @@ export interface SidebarActionProps { wide: boolean; rpc: Rpc; sessions: Session
55
55
  export const CHANNEL = '/qqchat'
56
56
  const STYLE_ID = 'dsh-qqchat/client-ui-v2'
57
57
 
58
+ const QQ_ICON_PATH = 'M17.5359 12.5144L16.8402 10.7175C16.8408 10.6968 16.8494 10.3429 16.8494 10.1604C16.8494 7.08792 15.448 4.0003 12.0012 4C8.55459 4.0003 7.15292 7.08792 7.15292 10.1604C7.15292 10.3429 7.16151 10.6968 7.16209 10.7175L6.4667 12.5144C6.27608 13.0285 6.08776 13.564 5.94988 14.0232C5.29262 16.2126 5.50559 17.1186 5.66783 17.139C6.01581 17.1823 7.02221 15.4908 7.02221 15.4908C7.02221 16.4704 7.5095 17.7487 8.56405 18.6719C8.16963 18.7976 7.68635 18.9911 7.37564 19.2284C7.09645 19.442 7.13142 19.6594 7.18158 19.7473C7.40258 20.1329 10.9713 19.9935 12.0017 19.8733C13.0319 19.9935 16.6009 20.1329 16.8216 19.7473C16.872 19.6594 16.9067 19.442 16.6275 19.2284C16.3168 18.9911 15.8333 18.7976 15.4386 18.6716C16.4928 17.7487 16.9801 16.4704 16.9801 15.4908C16.9801 15.4908 17.9868 17.1823 18.3348 17.139C18.4967 17.1186 18.7131 16.2108 18.0524 14.0232C17.9125 13.56 17.7265 13.0285 17.5359 12.5144ZM18.5574 20.7407C18.1843 21.3926 17.7237 21.6334 17.1187 21.7981C16.8792 21.8633 16.621 21.9056 16.325 21.936C15.8844 21.9814 15.3392 22.001 14.712 22C13.786 21.9985 12.693 21.9491 12.0017 21.884C11.3103 21.9491 10.2173 21.9985 9.29129 22C8.66414 22.001 8.11889 21.9814 7.67832 21.936C7.38236 21.9056 7.12409 21.8633 6.88467 21.7981C6.27994 21.6335 5.81954 21.393 5.44496 20.7393C5.15165 20.2258 5.07747 19.6406 5.20612 19.0866C4.61376 18.9546 4.20483 18.6045 3.92733 18.1757C3.77911 17.9466 3.68408 17.7127 3.61845 17.4663C3.53001 17.1344 3.49486 16.7666 3.50184 16.3601C3.51532 15.5749 3.68902 14.5984 4.03435 13.4481C4.17427 12.9821 4.3614 12.4396 4.6015 11.7926L5.15467 10.3632C5.1536 10.287 5.15292 10.2154 5.15292 10.1604C5.15292 5.6047 7.58875 2.00038 12.0013 2C16.4138 2.00038 18.8494 5.60454 18.8494 10.1604C18.8494 10.2154 18.8487 10.2869 18.8477 10.3631L19.401 11.7923L19.4112 11.8191C19.636 12.4254 19.8242 12.9722 19.967 13.445C20.3145 14.5956 20.4889 15.5735 20.5018 16.361C20.5085 16.768 20.4728 17.1365 20.3837 17.4689C20.3178 17.7148 20.2228 17.9483 20.0746 18.1768C19.7976 18.6041 19.3905 18.9532 18.7974 19.0862C18.9266 19.6411 18.8523 20.2274 18.5574 20.7407Z'
59
+
60
+ /** Temporarily replaces DSH's fallback settings glyph for the QQ Chat row. */
61
+ export function installQQSettingsIcon(): () => void {
62
+ const originals = new Map<HTMLButtonElement, SVGElement>()
63
+ const apply = () => {
64
+ for (const button of Array.from(document.querySelectorAll('button'))) {
65
+ const label = button.querySelector('span[class$="_navLabel"]')
66
+ if (label?.textContent?.trim() !== 'QQ Chat') continue
67
+ if (button.querySelector('[data-dsh-qqchat-icon]')) continue
68
+ const icon = button.querySelector('svg[class$="_navIcon"]')
69
+ if (!icon) continue
70
+ originals.set(button, icon.cloneNode(true) as SVGElement)
71
+ icon.remove()
72
+ const replacement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
73
+ replacement.setAttribute('width', '16')
74
+ replacement.setAttribute('height', '16')
75
+ replacement.setAttribute('viewBox', '0 0 24 24')
76
+ replacement.setAttribute('fill', 'currentColor')
77
+ replacement.setAttribute('aria-hidden', 'true')
78
+ replacement.setAttribute('data-dsh-qqchat-icon', 'true')
79
+ replacement.classList.add('dsh-qqchat-nav-icon')
80
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
81
+ path.setAttribute('d', QQ_ICON_PATH)
82
+ replacement.append(path)
83
+ button.insertBefore(replacement, label)
84
+ }
85
+ }
86
+ const observer = new MutationObserver(apply)
87
+ observer.observe(document.body, { childList: true, subtree: true })
88
+ apply()
89
+ return () => {
90
+ observer.disconnect()
91
+ for (const [button, original] of originals) {
92
+ const replacement = button.querySelector('[data-dsh-qqchat-icon]')
93
+ if (replacement) replacement.replaceWith(original)
94
+ }
95
+ originals.clear()
96
+ }
97
+ }
98
+
58
99
  const css = `
59
100
  .qqs{display:flex;flex-direction:column;gap:18px;color:var(--dsw-alias-label-primary);font:inherit}.qqs *{box-sizing:border-box}
60
101
  .qqsHead{display:flex;align-items:center;justify-content:space-between;gap:12px}.qqsBrand{display:flex;align-items:center;gap:10px}.qqsLogo{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);font-weight:700}.qqsTitle{font-size:15px;font-weight:620}.qqMuted{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.55}.qqStatus{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--dsw-alias-label-secondary)}.qqDot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-warn-primary)}.qqDot.online{background:var(--dsw-alias-state-success-primary)}
package/lib/client.js CHANGED
@@ -10,6 +10,7 @@ exports.inject = ['slots', 'connection', 'conversationEvents'];
10
10
  exports.apply = function apply(ctx) {
11
11
  (0, shared_cjs_1.setQQRpc)(ctx.connection.rpc);
12
12
  ctx.effect(shared_cjs_1.installStyles, 'dsh-qqchat: client styles');
13
+ ctx.effect(shared_cjs_1.installQQSettingsIcon, 'dsh-qqchat: QQ settings icon');
13
14
  ctx.conversationEvents.register((0, workspace_cjs_1.createQQMessageDefinition)());
14
15
  ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'qqchat', order: 35, label: () => 'QQ Chat', inject: () => ({ rpc: ctx.connection.rpc }) }, settings_cjs_1.QQSettings));
15
16
  ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'qqchat-message' }, workspace_cjs_1.QQTranscriptNode));
@@ -249,6 +250,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
249
250
  exports.dateTime = exports.time = exports.initials = exports.short = exports.CHANNEL = exports.h = exports.useSyncExternalStore = exports.useState = exports.useMemo = exports.useEffect = exports.useCallback = exports.Menu = exports.Input = exports.Button = exports.React = void 0;
250
251
  exports.setQQRpc = setQQRpc;
251
252
  exports.getQQRpc = getQQRpc;
253
+ exports.installQQSettingsIcon = installQQSettingsIcon;
252
254
  exports.installStyles = installStyles;
253
255
  exports.call = call;
254
256
  exports.Modal = Modal;
@@ -265,6 +267,49 @@ function setQQRpc(rpc) { qqRpc = rpc; }
265
267
  function getQQRpc() { return qqRpc; }
266
268
  exports.CHANNEL = '/qqchat';
267
269
  const STYLE_ID = 'dsh-qqchat/client-ui-v2';
270
+ const QQ_ICON_PATH = 'M17.5359 12.5144L16.8402 10.7175C16.8408 10.6968 16.8494 10.3429 16.8494 10.1604C16.8494 7.08792 15.448 4.0003 12.0012 4C8.55459 4.0003 7.15292 7.08792 7.15292 10.1604C7.15292 10.3429 7.16151 10.6968 7.16209 10.7175L6.4667 12.5144C6.27608 13.0285 6.08776 13.564 5.94988 14.0232C5.29262 16.2126 5.50559 17.1186 5.66783 17.139C6.01581 17.1823 7.02221 15.4908 7.02221 15.4908C7.02221 16.4704 7.5095 17.7487 8.56405 18.6719C8.16963 18.7976 7.68635 18.9911 7.37564 19.2284C7.09645 19.442 7.13142 19.6594 7.18158 19.7473C7.40258 20.1329 10.9713 19.9935 12.0017 19.8733C13.0319 19.9935 16.6009 20.1329 16.8216 19.7473C16.872 19.6594 16.9067 19.442 16.6275 19.2284C16.3168 18.9911 15.8333 18.7976 15.4386 18.6716C16.4928 17.7487 16.9801 16.4704 16.9801 15.4908C16.9801 15.4908 17.9868 17.1823 18.3348 17.139C18.4967 17.1186 18.7131 16.2108 18.0524 14.0232C17.9125 13.56 17.7265 13.0285 17.5359 12.5144ZM18.5574 20.7407C18.1843 21.3926 17.7237 21.6334 17.1187 21.7981C16.8792 21.8633 16.621 21.9056 16.325 21.936C15.8844 21.9814 15.3392 22.001 14.712 22C13.786 21.9985 12.693 21.9491 12.0017 21.884C11.3103 21.9491 10.2173 21.9985 9.29129 22C8.66414 22.001 8.11889 21.9814 7.67832 21.936C7.38236 21.9056 7.12409 21.8633 6.88467 21.7981C6.27994 21.6335 5.81954 21.393 5.44496 20.7393C5.15165 20.2258 5.07747 19.6406 5.20612 19.0866C4.61376 18.9546 4.20483 18.6045 3.92733 18.1757C3.77911 17.9466 3.68408 17.7127 3.61845 17.4663C3.53001 17.1344 3.49486 16.7666 3.50184 16.3601C3.51532 15.5749 3.68902 14.5984 4.03435 13.4481C4.17427 12.9821 4.3614 12.4396 4.6015 11.7926L5.15467 10.3632C5.1536 10.287 5.15292 10.2154 5.15292 10.1604C5.15292 5.6047 7.58875 2.00038 12.0013 2C16.4138 2.00038 18.8494 5.60454 18.8494 10.1604C18.8494 10.2154 18.8487 10.2869 18.8477 10.3631L19.401 11.7923L19.4112 11.8191C19.636 12.4254 19.8242 12.9722 19.967 13.445C20.3145 14.5956 20.4889 15.5735 20.5018 16.361C20.5085 16.768 20.4728 17.1365 20.3837 17.4689C20.3178 17.7148 20.2228 17.9483 20.0746 18.1768C19.7976 18.6041 19.3905 18.9532 18.7974 19.0862C18.9266 19.6411 18.8523 20.2274 18.5574 20.7407Z';
271
+ /** Temporarily replaces DSH's fallback settings glyph for the QQ Chat row. */
272
+ function installQQSettingsIcon() {
273
+ const originals = new Map();
274
+ const apply = () => {
275
+ for (const button of Array.from(document.querySelectorAll('button'))) {
276
+ const label = button.querySelector('span[class$="_navLabel"]');
277
+ if (label?.textContent?.trim() !== 'QQ Chat')
278
+ continue;
279
+ if (button.querySelector('[data-dsh-qqchat-icon]'))
280
+ continue;
281
+ const icon = button.querySelector('svg[class$="_navIcon"]');
282
+ if (!icon)
283
+ continue;
284
+ originals.set(button, icon.cloneNode(true));
285
+ icon.remove();
286
+ const replacement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
287
+ replacement.setAttribute('width', '16');
288
+ replacement.setAttribute('height', '16');
289
+ replacement.setAttribute('viewBox', '0 0 24 24');
290
+ replacement.setAttribute('fill', 'currentColor');
291
+ replacement.setAttribute('aria-hidden', 'true');
292
+ replacement.setAttribute('data-dsh-qqchat-icon', 'true');
293
+ replacement.classList.add('dsh-qqchat-nav-icon');
294
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
295
+ path.setAttribute('d', QQ_ICON_PATH);
296
+ replacement.append(path);
297
+ button.insertBefore(replacement, label);
298
+ }
299
+ };
300
+ const observer = new MutationObserver(apply);
301
+ observer.observe(document.body, { childList: true, subtree: true });
302
+ apply();
303
+ return () => {
304
+ observer.disconnect();
305
+ for (const [button, original] of originals) {
306
+ const replacement = button.querySelector('[data-dsh-qqchat-icon]');
307
+ if (replacement)
308
+ replacement.replaceWith(original);
309
+ }
310
+ originals.clear();
311
+ };
312
+ }
268
313
  const css = `
269
314
  .qqs{display:flex;flex-direction:column;gap:18px;color:var(--dsw-alias-label-primary);font:inherit}.qqs *{box-sizing:border-box}
270
315
  .qqsHead{display:flex;align-items:center;justify-content:space-between;gap:12px}.qqsBrand{display:flex;align-items:center;gap:10px}.qqsLogo{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);font-weight:700}.qqsTitle{font-size:15px;font-weight:620}.qqMuted{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.55}.qqStatus{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--dsw-alias-label-secondary)}.qqDot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-warn-primary)}.qqDot.online{background:var(--dsw-alias-state-success-primary)}
package/lib/index.js CHANGED
@@ -31,7 +31,10 @@ export function apply(ctx, inputConfig = {}) {
31
31
  const runtime = new QQChatRuntime(ctx, db, api, auth, memory, bridge, config, logger, media);
32
32
  ctx.connection.rpc.handle('/qqchat', createQQChatRpc(runtime), { authority: 'loopback' });
33
33
  ctx.effect(() => {
34
- void runtime.start().catch(error => logger.error?.(error));
34
+ void (async () => {
35
+ await bridge.ensureWorkspaceMembership();
36
+ await runtime.start();
37
+ })().catch(error => logger.error?.(error));
35
38
  return () => runtime.stop();
36
39
  }, 'dsh-qqchat runtime');
37
40
  }
@@ -22,6 +22,7 @@ export declare class DshQQBridge {
22
22
  private readonly activeAttachments;
23
23
  private readonly activeMediaReadable;
24
24
  private readonly memorySnapshots;
25
+ private qqWorkspace?;
25
26
  private readonly disposeEvent;
26
27
  private readonly disposeToolGate;
27
28
  private readonly disposeCommands;
@@ -30,12 +31,15 @@ export declare class DshQQBridge {
30
31
  constructor(ctx: Context, db: QQChatDatabase, memory: MemoryEngine, config: QQChatConfig, logger?: LoggerLike);
31
32
  dispose(): Promise<void>;
32
33
  ensureChatSession(chatType: ChatType, row: GroupRow | MemberRow): Promise<string>;
34
+ ensureWorkspaceMembership(): Promise<void>;
33
35
  recordTranscript(event: QQChatDisplayEvent, row: GroupRow | MemberRow, createSession?: boolean): Promise<string | undefined>;
34
36
  reply(message: QQNormalizedMessage, group: GroupRow | undefined, member: MemberRow, attachments?: StoredAttachmentSummary[], onTextDelta?: (delta: string) => void, isOwner?: boolean): Promise<string>;
35
37
  private onSessionEvent;
36
38
  private injectMemorySnapshot;
37
39
  private restoreMemorySnapshot;
38
40
  private ensureAgent;
41
+ /** Keep all QQ sessions together in one DSH Workspace using the host API. */
42
+ private attachToQQWorkspace;
39
43
  private isArchived;
40
44
  private rememberRoute;
41
45
  private appendDisplayIfMissing;
@@ -33,6 +33,7 @@ export class DshQQBridge {
33
33
  activeAttachments = new Map();
34
34
  activeMediaReadable = new Map();
35
35
  memorySnapshots = new Map();
36
+ qqWorkspace;
36
37
  disposeEvent;
37
38
  disposeToolGate;
38
39
  disposeCommands;
@@ -97,6 +98,16 @@ export class DshQQBridge {
97
98
  const { sessionId } = await this.ensureAgent(chatType, row);
98
99
  return sessionId;
99
100
  }
101
+ async ensureWorkspaceMembership() {
102
+ const sessionIds = [
103
+ ...this.db.listGroups().map(row => row.dsh_session_id),
104
+ ...this.db.listDirectChats().map(row => row.dsh_session_id),
105
+ ];
106
+ for (const sessionId of sessionIds) {
107
+ if (sessionId)
108
+ await this.attachToQQWorkspace(sessionId);
109
+ }
110
+ }
100
111
  async recordTranscript(event, row, createSession = false) {
101
112
  const existing = this.db.getChatSession(event.chatType, Number(row.id));
102
113
  if (!existing && !createSession)
@@ -274,6 +285,7 @@ export class DshQQBridge {
274
285
  this.ensureSelection(live);
275
286
  this.ensureTitle(live.session, chatType, row);
276
287
  this.rememberRoute(chatType, row, live, sessionId);
288
+ await this.attachToQQWorkspace(sessionId);
277
289
  return { agent: live, sessionId };
278
290
  }
279
291
  const resumed = await this.tryResume(sessionId);
@@ -281,6 +293,7 @@ export class DshQQBridge {
281
293
  this.ensureSelection(resumed.agent);
282
294
  this.ensureTitle(resumed.agent.session, chatType, row);
283
295
  this.rememberRoute(chatType, row, resumed.agent, sessionId);
296
+ await this.attachToQQWorkspace(sessionId);
284
297
  return { agent: resumed.agent, sessionId };
285
298
  }
286
299
  }
@@ -299,8 +312,30 @@ export class DshQQBridge {
299
312
  this.db.setChatSession(chatType, Number(row.id), sessionId);
300
313
  this.ensureTitle(handle.agent.session, chatType, row);
301
314
  this.rememberRoute(chatType, row, handle.agent, sessionId);
315
+ await this.attachToQQWorkspace(sessionId);
302
316
  return { agent: handle.agent, sessionId };
303
317
  }
318
+ /** Keep all QQ sessions together in one DSH Workspace using the host API. */
319
+ async attachToQQWorkspace(sessionId) {
320
+ const registry = this.ctx.workspaceRegistry;
321
+ if (!registry)
322
+ return;
323
+ if (!this.qqWorkspace) {
324
+ this.qqWorkspace = registry.create(process.cwd(), 'QQ Chat').catch(error => {
325
+ this.logger.warn?.(`[dsh-qqchat] QQ Workspace 创建失败,将保留在未分组: ${error instanceof Error ? error.message : String(error)}`);
326
+ return undefined;
327
+ });
328
+ }
329
+ const workspace = await this.qqWorkspace;
330
+ if (!workspace)
331
+ return;
332
+ try {
333
+ await workspace.attachSession(SessionId(sessionId));
334
+ }
335
+ catch (error) {
336
+ this.logger.warn?.(`[dsh-qqchat] QQ session ${sessionId} 加入 Workspace 失败,将保留在未分组: ${error instanceof Error ? error.message : String(error)}`);
337
+ }
338
+ }
304
339
  isArchived(sessionId) {
305
340
  const registry = this.ctx.workspaceRegistry;
306
341
  return registry?.archivedSessionIds.includes(sessionId) ?? false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-qqchat",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "DeepSeek Harness 的 QQ 私聊/群聊插件,支持扫码授权、原生 Session、群记忆与工具权限。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -40,7 +40,10 @@ export function apply(ctx: Context, inputConfig: QQChatConfigInput = {}): void {
40
40
 
41
41
  ctx.connection.rpc.handle('/qqchat', createQQChatRpc(runtime), { authority: 'loopback' })
42
42
  ctx.effect(() => {
43
- void runtime.start().catch(error => logger.error?.(error))
43
+ void (async () => {
44
+ await bridge.ensureWorkspaceMembership()
45
+ await runtime.start()
46
+ })().catch(error => logger.error?.(error))
44
47
  return () => runtime.stop()
45
48
  }, 'dsh-qqchat runtime')
46
49
  }
@@ -39,7 +39,11 @@ interface AgentPreset { id: string }
39
39
  interface AgentPresetService { resolve(id?: string): Promise<AgentPreset>; list(): Promise<AgentPreset[]>; mount(ctx: Context, id: string): Promise<unknown>; recompose(ctx: Context, id: string): Promise<AgentPreset> }
40
40
  interface SessionPersistenceService { inspect(id: SessionId): Promise<{ meta: Session['header']; events: readonly SessionEvent[] }> }
41
41
  interface SessionTitleService { get(session: Session): unknown; rename(session: Session, title: string): unknown }
42
- interface WorkspaceRegistryService { archivedSessionIds: readonly string[] }
42
+ interface WorkspaceEntity { attachSession(sessionId: SessionId): Promise<void> }
43
+ interface WorkspaceRegistryService {
44
+ archivedSessionIds: readonly string[]
45
+ create(path: string, title?: string): Promise<WorkspaceEntity>
46
+ }
43
47
  interface Composition { presetId?: string; setup?: AgentSetup }
44
48
  interface ActiveActor { chatType: ChatType; senderId: string }
45
49
 
@@ -62,6 +66,7 @@ export class DshQQBridge {
62
66
  private readonly activeAttachments = new Map<string, Set<string>>()
63
67
  private readonly activeMediaReadable = new Map<string, boolean>()
64
68
  private readonly memorySnapshots = new Map<string, MemorySnapshotState>()
69
+ private qqWorkspace?: Promise<WorkspaceEntity | undefined>
65
70
  private readonly disposeEvent: () => void
66
71
  private readonly disposeToolGate: () => void
67
72
  private readonly disposeCommands: () => void
@@ -122,6 +127,16 @@ export class DshQQBridge {
122
127
  return sessionId
123
128
  }
124
129
 
130
+ async ensureWorkspaceMembership(): Promise<void> {
131
+ const sessionIds = [
132
+ ...this.db.listGroups().map(row => row.dsh_session_id),
133
+ ...this.db.listDirectChats().map(row => row.dsh_session_id),
134
+ ]
135
+ for (const sessionId of sessionIds) {
136
+ if (sessionId) await this.attachToQQWorkspace(sessionId)
137
+ }
138
+ }
139
+
125
140
  async recordTranscript(event: QQChatDisplayEvent, row: GroupRow | MemberRow, createSession = false): Promise<string | undefined> {
126
141
  const existing = this.db.getChatSession(event.chatType, Number(row.id))
127
142
  if (!existing && !createSession) return undefined
@@ -291,6 +306,7 @@ export class DshQQBridge {
291
306
  this.ensureSelection(live)
292
307
  this.ensureTitle(live.session, chatType, row)
293
308
  this.rememberRoute(chatType, row, live, sessionId)
309
+ await this.attachToQQWorkspace(sessionId)
294
310
  return { agent: live, sessionId }
295
311
  }
296
312
  const resumed = await this.tryResume(sessionId)
@@ -298,6 +314,7 @@ export class DshQQBridge {
298
314
  this.ensureSelection(resumed.agent)
299
315
  this.ensureTitle(resumed.agent.session, chatType, row)
300
316
  this.rememberRoute(chatType, row, resumed.agent, sessionId)
317
+ await this.attachToQQWorkspace(sessionId)
301
318
  return { agent: resumed.agent, sessionId }
302
319
  }
303
320
  }
@@ -317,9 +334,29 @@ export class DshQQBridge {
317
334
  this.db.setChatSession(chatType, Number(row.id), sessionId)
318
335
  this.ensureTitle(handle.agent.session, chatType, row)
319
336
  this.rememberRoute(chatType, row, handle.agent, sessionId)
337
+ await this.attachToQQWorkspace(sessionId)
320
338
  return { agent: handle.agent, sessionId }
321
339
  }
322
340
 
341
+ /** Keep all QQ sessions together in one DSH Workspace using the host API. */
342
+ private async attachToQQWorkspace(sessionId: string): Promise<void> {
343
+ const registry = (this.ctx as unknown as { workspaceRegistry?: WorkspaceRegistryService }).workspaceRegistry
344
+ if (!registry) return
345
+ if (!this.qqWorkspace) {
346
+ this.qqWorkspace = registry.create(process.cwd(), 'QQ Chat').catch(error => {
347
+ this.logger.warn?.(`[dsh-qqchat] QQ Workspace 创建失败,将保留在未分组: ${error instanceof Error ? error.message : String(error)}`)
348
+ return undefined
349
+ })
350
+ }
351
+ const workspace = await this.qqWorkspace
352
+ if (!workspace) return
353
+ try {
354
+ await workspace.attachSession(SessionId(sessionId))
355
+ } catch (error) {
356
+ this.logger.warn?.(`[dsh-qqchat] QQ session ${sessionId} 加入 Workspace 失败,将保留在未分组: ${error instanceof Error ? error.message : String(error)}`)
357
+ }
358
+ }
359
+
323
360
  private isArchived(sessionId: string): boolean {
324
361
  const registry = (this.ctx as unknown as { workspaceRegistry?: WorkspaceRegistryService }).workspaceRegistry
325
362
  return registry?.archivedSessionIds.includes(sessionId) ?? false