dsh-qqchat 0.2.2 → 0.2.3

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
@@ -6,7 +6,8 @@
6
6
 
7
7
  <p>
8
8
  <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a>
9
- <img src="https://img.shields.io/badge/DSH-Plugin-6f42c1.svg" alt="DSH Plugin">
9
+ <img src="https://img.shields.io/badge/DSH-0.1.1--rc.2-6f42c1.svg" alt="DSH 0.1.1-rc.2">
10
+ <img src="https://img.shields.io/badge/QQChat-0.2.3-2ea44f.svg" alt="QQChat 0.2.3">
10
11
  <img src="https://img.shields.io/badge/TypeScript-5.x-3178c6.svg" alt="TypeScript">
11
12
  </p>
12
13
 
@@ -17,6 +18,8 @@
17
18
 
18
19
  <p><em>Chat in QQ, then view the complete Session, memory and tool activity in DSH Web.</em></p>
19
20
 
21
+ <p>This release is verified against DSH <code>0.1.1-rc.2</code>. Install the plugin as-is; no DSH source patch or rebuild is required.</p>
22
+
20
23
  `dsh-qqchat` is an official QQ Bot plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness). It maps QQ groups and direct chats to ordinary DSH Sessions and uses DSH's native Conversation, Agent, command and tool surfaces.
21
24
 
22
25
  > This is a Vibe Coding project, so code quality and engineering completeness may need improvement. If you find a problem or have a better implementation idea, please open an [Issue](https://github.com/Coffeiz/dsh-qqchat/issues) or submit a [Pull Request](https://github.com/Coffeiz/dsh-qqchat/pulls).
package/README.md CHANGED
@@ -6,7 +6,8 @@
6
6
 
7
7
  <p>
8
8
  <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT License"></a>
9
- <img src="https://img.shields.io/badge/DSH-Plugin-6f42c1.svg" alt="DSH Plugin">
9
+ <img src="https://img.shields.io/badge/DSH-0.1.1--rc.2-6f42c1.svg" alt="DSH 0.1.1-rc.2">
10
+ <img src="https://img.shields.io/badge/QQChat-0.2.3-2ea44f.svg" alt="QQChat 0.2.3">
10
11
  <img src="https://img.shields.io/badge/TypeScript-5.x-3178c6.svg" alt="TypeScript">
11
12
  </p>
12
13
 
@@ -18,6 +19,8 @@
18
19
 
19
20
  <p><em>dsh-qqchat 是 DeepSeek Harness(DSH)的 QQ Bot 插件,支持 QQ 群聊、私聊、会话隔离、记忆系统和 DSH 原生命令。</em></p>
20
21
 
22
+ <p>当前版本基于 DSH <code>0.1.1-rc.2</code> 验证,安装插件即可使用,无需修改或重新构建 DSH。</p>
23
+
21
24
  > 本项目是一个 Vibe Coding 项目,代码质量和工程完整性可能需要改进。如果你发现问题或有更好的实现建议,欢迎提交 [Issue](https://github.com/Coffeiz/dsh-qqchat/issues) 或 [Pull Request](https://github.com/Coffeiz/dsh-qqchat/pulls)。
22
25
 
23
26
  </div>
@@ -127,6 +127,11 @@ session.append('qqchat/message', ...)
127
127
  - 不会唤醒 Agent;
128
128
  - 不会调用 LLM。
129
129
 
130
+ 官方 DSH 当前不会为 out-of-tree 插件事件提供正式注册 API。QQChat 在插件加载时
131
+ 按兼容约定把 `qqchat/message` 注册到可达的 DSH `KNOWN_SESSION_EVENT_TYPES` 副本,
132
+ 因此正常安装后旧 Session 可以继续恢复;如果某个特殊打包环境无法解析到宿主使用的
133
+ 事件表,QQChat 会捕获恢复失败并创建新的 QQ Session,完整 QQ 历史仍保存在 SQLite。
134
+
130
135
  插件不再从 SQLite 重放整段历史到 DSH Session。DSH Session 保存它实际经历过的 display/Agent events,完整 QQ 世界历史始终由 SQLite 负责。
131
136
 
132
137
  ## Host / Client
@@ -10,7 +10,32 @@ QQChat 是 TypeScript 插件,Host 和 Client 一起构建。日常开发不需
10
10
  - 可用的 DSH workspace
11
11
  - 需要真实网关验证时,准备 QQ 官方 Bot 凭据;凭据只放在本地 Host 配置中
12
12
 
13
- 仓库当前不提交 `package-lock.json`,使用 `npm install` 安装依赖。
13
+ 仓库提交 `package-lock.json`,使用 `npm install` 安装依赖并保持发布安装结果一致。
14
+
15
+ ## DSH 兼容要求
16
+
17
+ 插件以官方发布版 DSH 为运行基线。安装者不需要修改 DSH 源码、锁文件或构建流程;插件自身也不能依赖本地 DSH patch 或未发布的私有接口。
18
+
19
+ 验证插件兼容性时,应在干净的官方 DSH 工作区中检查:
20
+
21
+ 1. DSH 本地工作区没有插件补丁或未提交修改。
22
+ 2. 插件依赖的 DSH 包版本与官方发布版本一致。
23
+ 3. 插件 `npm run check` 能独立通过。
24
+ 4. 启动 DSH Web 后,插件 Host、Client 和设置页都能正常加载。
25
+
26
+ 当前已知的宿主兼容边界见[架构说明](./ARCHITECTURE.md)和[媒体与引用方案](./MEDIA_AND_QUOTES.md):自定义 `qqchat/message` 展示事件不属于官方 DSH 的标准 Agent 事件,插件会在加载时自动注册事件类型;若特殊打包环境无法找到宿主事件表,才会触发新建 Session 的降级,QQ 完整历史仍由自身 SQLite 保存。该兼容层不要求用户修改 DSH。
27
+
28
+ ## 当前功能兼容矩阵
29
+
30
+ | 功能 | DSH 原生支持情况 | 插件行为 |
31
+ | --- | --- | --- |
32
+ | QQ 文本消息与独立 Session | 无 QQ 协议支持 | 转换为 DSH 标准 user message,并保留 QQ 身份元数据 |
33
+ | 群友静默记录、未 @ 消息展示 | 无 QQ 自定义展示事件 | 使用插件 Client 渲染 `qqchat/message`;不送入模型 |
34
+ | 图片输入 | 取决于当前 DSH 模型路由和附件服务 | 使用 DSH 原生图片块;不支持视觉时使用稳定文本降级和读取工具 |
35
+ | 音频、视频和普通文件 | DSH 不提供统一的 QQ 媒体理解能力 | 保存附件并提供类型提示、文件工具或媒体信息工具 |
36
+ | QQ 私聊流式回复 | DSH 不负责 QQ Gateway 流式协议 | 插件调用 QQ 官方接口;失败时回退普通发送,纯文本兼容模式自动关闭 |
37
+ | QQ 引用、@和气泡 | DSH 不认识 QQ 协议字段 | 插件把引用和 @ 转为可读文本/元数据,并由 Client 做展示 |
38
+ | DSH 原生命令 | DSH 只处理 DSH Web 自己收到的命令 | 插件将 `/qq...` 命令解析后转交或执行,避免与 DSH 原命令冲突 |
14
39
 
15
40
  ## 本地安装与检查
16
41
 
@@ -280,20 +280,21 @@ QQ dispatch
280
280
 
281
281
  ## 模型输入策略
282
282
 
283
- ### 第一阶段:官方兼容模式
283
+ ### 兼容与降级路径
284
284
 
285
- 默认采用官方 `dsh-qqbot` 的思路:
285
+ 当前以 DSH 原生图片输入为主,无法使用原生视觉输入时由 DSH 统一降级:
286
286
 
287
287
  - 图片、视频、文件先保存为附件;
288
- - Agent 文本上下文注入媒体类型、名称和附件 ID/受控本地路径;
289
- - 图片通过 `qqchat_describe_image` 或等价工具分析;
288
+ - Agent 文本上下文保留媒体类型、名称和附件 ID;
289
+ - 图片优先作为 DSH `ImageBlock` 输入;纯文本模型收到 DSH 生成的稳定图片占位文本;
290
+ - `qqchat_describe_image` 仅用于模型主动按 attachment ID 重新读取图片;
290
291
  - 视频交给 `ffmpeg` 和文件工具;
291
292
  - 文本文件可以直接读取内容;
292
293
  - 无能力处理的媒体只提供结构化提示。
293
294
 
294
- 优点是对模型要求低,不会改变所有 QQ turn 的模型路由,也不会把大量二进制内容直接塞进上下文缓存。
295
+ 优点是沿用 DSH 的统一模型能力判断、图片预算和附件处理,不需要插件复制一套模型适配逻辑。
295
296
 
296
- ### 第二阶段:图片原生输入
297
+ ### 图片原生输入
297
298
 
298
299
  当当前模型明确声明支持 image modality 时,可以将图片附件转换为 DSH 官方 `ImageAttachmentRef`,构造 `ImageBlock` 发送给视觉模型。
299
300
 
@@ -395,7 +396,7 @@ createUserMessage({
395
396
  })
396
397
  ```
397
398
 
398
- 这样图片由 DSH Web 原生消息渲染,同时可以进入视觉模型。文本模型或附件服务不可用时,必须退回当前的文本提示和 `qqchat_describe_image` 工具路径,不能让整轮消息失败。
399
+ 这样图片由 DSH Web 原生消息渲染,同时可以进入视觉模型。文本模型会由 DSH 统一投影为稳定的图片占位文本;`qqchat_describe_image` 仅作为按 attachment_id 主动重新读取图片的工具,不是正常视觉输入的必经路径。
399
400
 
400
401
  #### 静默消息:最小适配的附件读取 RPC
401
402
 
@@ -500,7 +501,7 @@ createUserMessage({
500
501
  - [x] 当前模型支持图片时,将 QQ 图片保存为 DSH `ImageAttachmentRef`;
501
502
  - [x] 在同一条 DSH `user/message` 中加入文本块和 `ImageBlock`;
502
503
  - [x] 让 DSH Web 原生显示 Agent 图片,同时让视觉模型直接接收图片;
503
- - [x] 当前模型不支持图片时,退回 `qqchat_describe_image` 工具;
504
+ - [x] 当前模型不支持图片时,由 DSH 统一投影为稳定图片占位文本;主动重新读取图片仍可使用 `qqchat_describe_image`;
504
505
  - [x] 附件服务不可用、图片损坏或图片超限时,保留文本提示/工具 fallback,不让整轮媒体失败;
505
506
  - [x] 保证所有 `ImageBlock` 在 Session event 发布前已经持久提交;
506
507
  - [x] 保证 Session event 中不存在 Buffer、Blob、本地路径、SDK 原始对象或循环引用。
package/lib/index.js CHANGED
@@ -10,6 +10,8 @@ import { QQApiClient } from './gateway/api.js';
10
10
  import { QQBindService } from './gateway/auth.js';
11
11
  import { createQQChatRpc } from './transport/rpc.js';
12
12
  import { QQChatRuntime } from './session/runtime.js';
13
+ import { registerQQChatSessionEventType } from './session/registration.js';
14
+ const qqChatSessionEventRegistrations = await registerQQChatSessionEventType();
13
15
  export const name = 'dsh-qqchat';
14
16
  export const inject = ['connection', 'agents', 'agentDefaultModel', 'commands', 'llm', 'tools', 'workspaceRegistry'];
15
17
  export function apply(ctx, inputConfig = {}) {
@@ -18,6 +20,9 @@ export function apply(ctx, inputConfig = {}) {
18
20
  const db = new QQChatDatabase(join(config.dataDir, 'qqchat.sqlite'));
19
21
  const baseLogger = ctx.logger || console;
20
22
  const logger = new QQChatLogger(baseLogger);
23
+ if (qqChatSessionEventRegistrations === 0) {
24
+ logger.warn?.('[dsh-qqchat] 未找到可注册的 DSH Session 事件表,旧 QQChat Session 恢复可能需要新建 Session');
25
+ }
21
26
  const api = new QQApiClient(db, config);
22
27
  const auth = new QQBindService(db, config);
23
28
  const memory = new MemoryEngine(ctx, db, config, logger);
@@ -1,4 +1,4 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
2
  import type { QQChatDatabase } from '../storage/db.js';
3
- /** Exposes stored QQ images through DSH's durable image-block seam. */
3
+ /** Exposes stored QQ images by attachment ID when a model explicitly requests one. */
4
4
  export declare function registerQQImageTool(ctx: Context, db: QQChatDatabase, isAllowed?: (agentId: string, attachmentId: string) => boolean): () => void;
@@ -1,9 +1,9 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
- /** Exposes stored QQ images through DSH's durable image-block seam. */
2
+ /** Exposes stored QQ images by attachment ID when a model explicitly requests one. */
3
3
  export function registerQQImageTool(ctx, db, isAllowed = () => true) {
4
4
  const definition = defineTool({
5
5
  name: 'qqchat_describe_image',
6
- description: '查看 QQ 消息中的图片。传入 QQChat 提供的 attachment_id,可用于 OCR、截图、界面和照片分析。',
6
+ description: '按 attachment_id 主动重新读取 QQ 消息中的图片。正常图片消息由 DSH 原生视觉输入链路直接处理;本工具用于模型需要再次读取指定图片的场景。',
7
7
  parameters: {
8
8
  attachment_id: { type: 'string', required: true, description: 'QQChat 图片附件 ID' },
9
9
  },
@@ -1,4 +1,5 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm';
2
3
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
3
4
  import '../shared/augmentations.js';
4
5
  import type { QQChatDatabase } from '../storage/db.js';
@@ -42,7 +43,6 @@ export declare class DshQQBridge {
42
43
  private ensureTitle;
43
44
  private tryResume;
44
45
  private agentOptions;
45
- private imageRouteAvailable;
46
46
  private ensureSelection;
47
47
  private selection;
48
48
  private requestReset;
@@ -56,3 +56,5 @@ export declare class DshQQBridge {
56
56
  private persistedPreset;
57
57
  private serial;
58
58
  }
59
+ /** Build the durable DSH content for one QQ turn without pre-flighting the model. */
60
+ export declare function qqMessageContent(text: string, attachments: readonly StoredAttachmentSummary[]): ContentBlock[];
@@ -104,11 +104,12 @@ export class DshQQBridge {
104
104
  const { agent, sessionId } = await this.ensureAgent(event.chatType, row);
105
105
  event.sessionId = sessionId;
106
106
  if (event.isOwner) {
107
- const hasImages = event.attachments?.some(attachment => attachment.kind === 'image' && this.db.attachmentById(attachment.id)?.imageRef) === true;
108
- if (!hasImages || await this.imageRouteAvailable(agent))
109
- this.appendOwnerMessageIfMissing(agent.session, event);
110
- else
111
- this.appendDisplayIfMissing(agent.session, event);
107
+ // Keep owner messages as native user/message events even when they carry
108
+ // images. DSH now owns the image projection: visual routes receive the
109
+ // durable image block, while text-only routes receive its stable text
110
+ // placeholder. Falling back to a display-only event here would discard
111
+ // the owner's message semantics before DSH can apply that policy.
112
+ this.appendOwnerMessageIfMissing(agent.session, event);
112
113
  return sessionId;
113
114
  }
114
115
  this.appendDisplayIfMissing(agent.session, event);
@@ -157,15 +158,7 @@ export class DshQQBridge {
157
158
  : this.memory.contextForMember(member);
158
159
  this.injectMemorySnapshot(agent, sessionId, contextText);
159
160
  }
160
- const currentText = this.memory.currentMessageText(message) + mediaPrompt(attachments);
161
- const currentContent = [{ type: 'text', text: currentText }];
162
- if (attachments.length && await this.imageRouteAvailable(agent)) {
163
- for (const attachment of attachments) {
164
- if (attachment.kind === 'image' && attachment.imageRef) {
165
- currentContent.push({ type: 'image', attachment: attachment.imageRef });
166
- }
167
- }
168
- }
161
+ const currentContent = qqMessageContent(this.memory.currentMessageText(message), attachments);
169
162
  const current = createUserMessage({
170
163
  source: {
171
164
  kind: 'qq-chat', botId: String(message.accountId), chatType: message.chatType,
@@ -325,7 +318,11 @@ export class DshQQBridge {
325
318
  appendDisplayIfMissing(session, event) {
326
319
  if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId))
327
320
  return;
328
- session.append('qqchat/message', displayEventPayload(event), { ignorable: true });
321
+ // Keep this on the public Session.append signature. Official DSH releases
322
+ // do not yet expose the optional ignorable envelope marker; if an older
323
+ // DSH cannot restore this plugin-only event, ensureAgent falls back to a
324
+ // fresh Session while SQLite remains the QQ history source of truth.
325
+ session.append('qqchat/message', displayEventPayload(event));
329
326
  }
330
327
  appendOwnerMessageIfMissing(session, event) {
331
328
  if (session.events.some(item => {
@@ -387,18 +384,6 @@ export class DshQQBridge {
387
384
  options.maxTokens = this.config.maxTokens;
388
385
  return options;
389
386
  }
390
- async imageRouteAvailable(agent) {
391
- const route = this.selection(agent)?.current || { provider: agent.options.provider, model: agent.options.model };
392
- if (!route.provider || !route.model)
393
- return false;
394
- try {
395
- const info = await this.ctx.llm.resolveModelInfo(route.provider, route.model);
396
- return info.inputModalities?.includes('image') === true;
397
- }
398
- catch {
399
- return false;
400
- }
401
- }
402
387
  ensureSelection(agent) {
403
388
  const id = String(agent.id);
404
389
  const existing = this.selections.get(id);
@@ -593,7 +578,17 @@ function mediaPrompt(attachments) {
593
578
  if (!attachments.length)
594
579
  return '';
595
580
  const lines = attachments.map(item => `- ${item.kind}: ${item.filename} (attachment_id=${item.id}${item.quoted ? ', 引用消息附件' : ''})`);
596
- return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片可使用 qqchat_describe_image 查看。`;
581
+ return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片由 DSH 根据当前模型能力处理;视觉模型可直接理解图片。`;
582
+ }
583
+ /** Build the durable DSH content for one QQ turn without pre-flighting the model. */
584
+ export function qqMessageContent(text, attachments) {
585
+ const content = [{ type: 'text', text: text + mediaPrompt([...attachments]) }];
586
+ for (const attachment of attachments) {
587
+ if (attachment.kind === 'image' && attachment.imageRef) {
588
+ content.push({ type: 'image', attachment: attachment.imageRef });
589
+ }
590
+ }
591
+ return content;
597
592
  }
598
593
  function transcriptContent(event, db) {
599
594
  const attachments = event.attachments || [];
@@ -0,0 +1,14 @@
1
+ export interface SessionEventRegistrationOptions {
2
+ readonly argv?: readonly string[];
3
+ readonly dshHome?: string;
4
+ readonly anchors?: readonly string[];
5
+ }
6
+ export declare function profileNameFromArgv(argv: readonly string[]): string | undefined;
7
+ /**
8
+ * Register the plugin's log-only event in every reachable dsh-session copy.
9
+ *
10
+ * DSH does not expose a public downstream event registration API yet. This
11
+ * small compatibility shim follows the ecosystem convention used by other
12
+ * DSH plugins and is intentionally idempotent and best-effort.
13
+ */
14
+ export declare function registerQQChatSessionEventType(options?: SessionEventRegistrationOptions): Promise<number>;
@@ -0,0 +1,94 @@
1
+ import { createRequire } from 'node:module';
2
+ import { realpathSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ const QQCHAT_SESSION_EVENT_TYPE = 'qqchat/message';
7
+ export function profileNameFromArgv(argv) {
8
+ const profileIndex = argv.indexOf('--profile');
9
+ if (profileIndex >= 0) {
10
+ const value = argv[profileIndex + 1];
11
+ if (value && !value.startsWith('-'))
12
+ return value;
13
+ }
14
+ const inline = argv.find(value => value.startsWith('--profile='));
15
+ const value = inline?.slice('--profile='.length);
16
+ return value && !value.startsWith('-') ? value : undefined;
17
+ }
18
+ /**
19
+ * Register the plugin's log-only event in every reachable dsh-session copy.
20
+ *
21
+ * DSH does not expose a public downstream event registration API yet. This
22
+ * small compatibility shim follows the ecosystem convention used by other
23
+ * DSH plugins and is intentionally idempotent and best-effort.
24
+ */
25
+ export async function registerQQChatSessionEventType(options = {}) {
26
+ const argv = options.argv || process.argv;
27
+ const profileName = profileNameFromArgv(argv);
28
+ const dshHome = options.dshHome || process.env.DSH_HOME || join(homedir(), '.dsh');
29
+ const profileAnchors = profileName
30
+ ? [
31
+ join(dshHome, 'profiles', profileName, 'package.json'),
32
+ join(dshHome, 'profiles', 'package.json'),
33
+ ]
34
+ : [];
35
+ const anchors = [...(options.anchors || [import.meta.url, argv[1]]), ...profileAnchors].filter((anchor) => typeof anchor === 'string' && anchor.length > 0);
36
+ const registeredPaths = new Set();
37
+ let registered = 0;
38
+ for (const anchor of anchors) {
39
+ try {
40
+ const require = createRequire(anchor);
41
+ registered += await registerSessionCopy(require, registeredPaths);
42
+ // Persistence may resolve its own peer dependency from a different
43
+ // package tree. Register the copy selected by that validator too.
44
+ try {
45
+ const persistence = require.resolve('@deepseek-ai/dsh-session-persistence');
46
+ registered += await registerSessionCopy(createRequire(persistence), registeredPaths);
47
+ }
48
+ catch {
49
+ // Some minimal profiles do not ship the persistence package.
50
+ }
51
+ }
52
+ catch {
53
+ // A profile may not expose this package from one of the anchors. The
54
+ // other anchor can still reach the validator used by the running host.
55
+ }
56
+ }
57
+ return registered;
58
+ }
59
+ async function registerSessionCopy(require, registeredPaths) {
60
+ const resolved = require.resolve('@deepseek-ai/dsh-session');
61
+ const candidates = [resolved];
62
+ // The DSH development CLI can resolve workspace source through tsx while
63
+ // the published package resolves lib/. Register both when source exists.
64
+ const packageRoot = dirname(dirname(resolved));
65
+ candidates.push(join(packageRoot, 'src', 'index.ts'));
66
+ candidates.push(join(packageRoot, 'src', 'index.js'));
67
+ let registered = 0;
68
+ for (const candidate of candidates) {
69
+ let physical;
70
+ try {
71
+ physical = realpathSync(candidate);
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ if (registeredPaths.has(physical))
77
+ continue;
78
+ registeredPaths.add(physical);
79
+ try {
80
+ // DSH publishes ESM builds. Dynamic import also handles a CJS build and
81
+ // preserves the actual module instance selected by the host anchor.
82
+ const module = await import(pathToFileURL(physical).href);
83
+ if (module.KNOWN_SESSION_EVENT_TYPES?.add) {
84
+ module.KNOWN_SESSION_EVENT_TYPES.add(QQCHAT_SESSION_EVENT_TYPE);
85
+ registered += 1;
86
+ }
87
+ }
88
+ catch {
89
+ // A published package may not ship its source entry; the lib entry above
90
+ // remains the normal registration target.
91
+ }
92
+ }
93
+ return registered;
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-qqchat",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "DeepSeek Harness 的 QQ 私聊/群聊插件,支持扫码授权、原生 Session、群记忆与工具权限。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,17 +46,18 @@
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@deepseek-ai/cordis": "^4.0.1",
49
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
50
- "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.8",
51
- "@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
52
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8",
53
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
54
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.8",
55
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.8",
56
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
57
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
58
- "@deepseek-ai/dsh-session": "^0.1.0-rc.8",
59
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.8"
49
+ "@deepseek-ai/dsh-attachment": "^0.1.1-rc.2",
50
+ "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
51
+ "@deepseek-ai/dsh-agent-presets": "^0.1.1-rc.2",
52
+ "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
53
+ "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
54
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
55
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
56
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
57
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
58
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
59
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
60
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
60
61
  },
61
62
  "peerDependenciesMeta": {
62
63
  "@deepseek-ai/dsh-client-connection": {
@@ -73,10 +74,11 @@
73
74
  }
74
75
  },
75
76
  "devDependencies": {
76
- "@deepseek-ai/dsh-agent-default-model": "^0.1.0-rc.8",
77
- "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.8",
78
- "@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
79
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8",
77
+ "@deepseek-ai/dsh-attachment": "^0.1.1-rc.2",
78
+ "@deepseek-ai/dsh-agent-default-model": "^0.1.1-rc.2",
79
+ "@deepseek-ai/dsh-agent-presets": "^0.1.1-rc.2",
80
+ "@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
81
+ "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
80
82
  "@types/node": "^22.18.0",
81
83
  "@types/qrcode": "^1.5.5",
82
84
  "@types/react": "^19.1.0",
package/src/index.ts CHANGED
@@ -14,8 +14,11 @@ import { QQApiClient } from './gateway/api.js'
14
14
  import { QQBindService } from './gateway/auth.js'
15
15
  import { createQQChatRpc } from './transport/rpc.js'
16
16
  import { QQChatRuntime } from './session/runtime.js'
17
+ import { registerQQChatSessionEventType } from './session/registration.js'
17
18
  import type { LoggerLike, QQChatConfigInput } from './types.js'
18
19
 
20
+ const qqChatSessionEventRegistrations = await registerQQChatSessionEventType()
21
+
19
22
  export const name = 'dsh-qqchat'
20
23
  export const inject = ['connection', 'agents', 'agentDefaultModel', 'commands', 'llm', 'tools', 'workspaceRegistry'] as const
21
24
 
@@ -25,6 +28,9 @@ export function apply(ctx: Context, inputConfig: QQChatConfigInput = {}): void {
25
28
  const db = new QQChatDatabase(join(config.dataDir, 'qqchat.sqlite'))
26
29
  const baseLogger = (ctx as unknown as { logger?: LoggerLike }).logger || console
27
30
  const logger = new QQChatLogger(baseLogger)
31
+ if (qqChatSessionEventRegistrations === 0) {
32
+ logger.warn?.('[dsh-qqchat] 未找到可注册的 DSH Session 事件表,旧 QQChat Session 恢复可能需要新建 Session')
33
+ }
28
34
  const api = new QQApiClient(db, config)
29
35
  const auth = new QQBindService(db, config)
30
36
  const memory = new MemoryEngine(ctx, db, config, logger)
@@ -2,11 +2,11 @@ import type { Context } from '@deepseek-ai/cordis'
2
2
  import { defineTool } from '@deepseek-ai/dsh-tools'
3
3
  import type { QQChatDatabase } from '../storage/db.js'
4
4
 
5
- /** Exposes stored QQ images through DSH's durable image-block seam. */
5
+ /** Exposes stored QQ images by attachment ID when a model explicitly requests one. */
6
6
  export function registerQQImageTool(ctx: Context, db: QQChatDatabase, isAllowed: (agentId: string, attachmentId: string) => boolean = () => true): () => void {
7
7
  const definition = defineTool({
8
8
  name: 'qqchat_describe_image',
9
- description: '查看 QQ 消息中的图片。传入 QQChat 提供的 attachment_id,可用于 OCR、截图、界面和照片分析。',
9
+ description: '按 attachment_id 主动重新读取 QQ 消息中的图片。正常图片消息由 DSH 原生视觉输入链路直接处理;本工具用于模型需要再次读取指定图片的场景。',
10
10
  parameters: {
11
11
  attachment_id: { type: 'string', required: true, description: 'QQChat 图片附件 ID' },
12
12
  },
@@ -128,9 +128,12 @@ export class DshQQBridge {
128
128
  const { agent, sessionId } = await this.ensureAgent(event.chatType, row)
129
129
  event.sessionId = sessionId
130
130
  if (event.isOwner) {
131
- const hasImages = event.attachments?.some(attachment => attachment.kind === 'image' && this.db.attachmentById(attachment.id)?.imageRef) === true
132
- if (!hasImages || await this.imageRouteAvailable(agent)) this.appendOwnerMessageIfMissing(agent.session, event)
133
- else this.appendDisplayIfMissing(agent.session, event)
131
+ // Keep owner messages as native user/message events even when they carry
132
+ // images. DSH now owns the image projection: visual routes receive the
133
+ // durable image block, while text-only routes receive its stable text
134
+ // placeholder. Falling back to a display-only event here would discard
135
+ // the owner's message semantics before DSH can apply that policy.
136
+ this.appendOwnerMessageIfMissing(agent.session, event)
134
137
  return sessionId
135
138
  }
136
139
  this.appendDisplayIfMissing(agent.session, event)
@@ -178,15 +181,7 @@ export class DshQQBridge {
178
181
  this.injectMemorySnapshot(agent, sessionId, contextText)
179
182
  }
180
183
 
181
- const currentText = this.memory.currentMessageText(message) + mediaPrompt(attachments)
182
- const currentContent: ContentBlock[] = [{ type: 'text', text: currentText }]
183
- if (attachments.length && await this.imageRouteAvailable(agent)) {
184
- for (const attachment of attachments) {
185
- if (attachment.kind === 'image' && attachment.imageRef) {
186
- currentContent.push({ type: 'image', attachment: attachment.imageRef })
187
- }
188
- }
189
- }
184
+ const currentContent = qqMessageContent(this.memory.currentMessageText(message), attachments)
190
185
  const current = createUserMessage({
191
186
  source: {
192
187
  kind: 'qq-chat', botId: String(message.accountId), chatType: message.chatType,
@@ -340,7 +335,11 @@ export class DshQQBridge {
340
335
 
341
336
  private appendDisplayIfMissing(session: Session, event: QQChatDisplayEvent): void {
342
337
  if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId)) return
343
- session.append('qqchat/message', displayEventPayload(event), { ignorable: true })
338
+ // Keep this on the public Session.append signature. Official DSH releases
339
+ // do not yet expose the optional ignorable envelope marker; if an older
340
+ // DSH cannot restore this plugin-only event, ensureAgent falls back to a
341
+ // fresh Session while SQLite remains the QQ history source of truth.
342
+ session.append('qqchat/message', displayEventPayload(event))
344
343
  }
345
344
 
346
345
  private appendOwnerMessageIfMissing(session: Session, event: QQChatDisplayEvent): void {
@@ -401,15 +400,6 @@ export class DshQQBridge {
401
400
  return options
402
401
  }
403
402
 
404
- private async imageRouteAvailable(agent: AgentHandle['agent']): Promise<boolean> {
405
- const route = this.selection(agent)?.current || { provider: agent.options.provider, model: agent.options.model }
406
- if (!route.provider || !route.model) return false
407
- try {
408
- const info = await this.ctx.llm.resolveModelInfo(route.provider, route.model)
409
- return info.inputModalities?.includes('image') === true
410
- } catch { return false }
411
- }
412
-
413
403
  private ensureSelection(agent: AgentHandle['agent']): ModelSelectionRef | undefined {
414
404
  const id = String(agent.id)
415
405
  const existing = this.selections.get(id)
@@ -598,7 +588,18 @@ export class DshQQBridge {
598
588
  function mediaPrompt(attachments: StoredAttachmentSummary[]): string {
599
589
  if (!attachments.length) return ''
600
590
  const lines = attachments.map(item => `- ${item.kind}: ${item.filename} (attachment_id=${item.id}${item.quoted ? ', 引用消息附件' : ''})`)
601
- return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片可使用 qqchat_describe_image 查看。`
591
+ return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片由 DSH 根据当前模型能力处理;视觉模型可直接理解图片。`
592
+ }
593
+
594
+ /** Build the durable DSH content for one QQ turn without pre-flighting the model. */
595
+ export function qqMessageContent(text: string, attachments: readonly StoredAttachmentSummary[]): ContentBlock[] {
596
+ const content: ContentBlock[] = [{ type: 'text', text: text + mediaPrompt([...attachments]) }]
597
+ for (const attachment of attachments) {
598
+ if (attachment.kind === 'image' && attachment.imageRef) {
599
+ content.push({ type: 'image', attachment: attachment.imageRef })
600
+ }
601
+ }
602
+ return content
602
603
  }
603
604
 
604
605
  function transcriptContent(event: QQChatDisplayEvent, db: QQChatDatabase): ContentBlock[] {
@@ -0,0 +1,102 @@
1
+ import { createRequire } from 'node:module'
2
+ import { realpathSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
6
+
7
+ const QQCHAT_SESSION_EVENT_TYPE = 'qqchat/message'
8
+
9
+ interface KnownEventTypesModule {
10
+ KNOWN_SESSION_EVENT_TYPES?: { add(type: string): unknown }
11
+ }
12
+
13
+ export interface SessionEventRegistrationOptions {
14
+ readonly argv?: readonly string[]
15
+ readonly dshHome?: string
16
+ readonly anchors?: readonly string[]
17
+ }
18
+
19
+ export function profileNameFromArgv(argv: readonly string[]): string | undefined {
20
+ const profileIndex = argv.indexOf('--profile')
21
+ if (profileIndex >= 0) {
22
+ const value = argv[profileIndex + 1]
23
+ if (value && !value.startsWith('-')) return value
24
+ }
25
+ const inline = argv.find(value => value.startsWith('--profile='))
26
+ const value = inline?.slice('--profile='.length)
27
+ return value && !value.startsWith('-') ? value : undefined
28
+ }
29
+
30
+ /**
31
+ * Register the plugin's log-only event in every reachable dsh-session copy.
32
+ *
33
+ * DSH does not expose a public downstream event registration API yet. This
34
+ * small compatibility shim follows the ecosystem convention used by other
35
+ * DSH plugins and is intentionally idempotent and best-effort.
36
+ */
37
+ export async function registerQQChatSessionEventType(options: SessionEventRegistrationOptions = {}): Promise<number> {
38
+ const argv = options.argv || process.argv
39
+ const profileName = profileNameFromArgv(argv)
40
+ const dshHome = options.dshHome || process.env.DSH_HOME || join(homedir(), '.dsh')
41
+ const profileAnchors = profileName
42
+ ? [
43
+ join(dshHome, 'profiles', profileName, 'package.json'),
44
+ join(dshHome, 'profiles', 'package.json'),
45
+ ]
46
+ : []
47
+ const anchors = [...(options.anchors || [import.meta.url, argv[1]]), ...profileAnchors].filter(
48
+ (anchor): anchor is string => typeof anchor === 'string' && anchor.length > 0,
49
+ )
50
+ const registeredPaths = new Set<string>()
51
+ let registered = 0
52
+
53
+ for (const anchor of anchors) {
54
+ try {
55
+ const require = createRequire(anchor)
56
+ registered += await registerSessionCopy(require, registeredPaths)
57
+ // Persistence may resolve its own peer dependency from a different
58
+ // package tree. Register the copy selected by that validator too.
59
+ try {
60
+ const persistence = require.resolve('@deepseek-ai/dsh-session-persistence')
61
+ registered += await registerSessionCopy(createRequire(persistence), registeredPaths)
62
+ } catch {
63
+ // Some minimal profiles do not ship the persistence package.
64
+ }
65
+ } catch {
66
+ // A profile may not expose this package from one of the anchors. The
67
+ // other anchor can still reach the validator used by the running host.
68
+ }
69
+ }
70
+
71
+ return registered
72
+ }
73
+
74
+ async function registerSessionCopy(require: NodeRequire, registeredPaths: Set<string>): Promise<number> {
75
+ const resolved = require.resolve('@deepseek-ai/dsh-session')
76
+ const candidates = [resolved]
77
+ // The DSH development CLI can resolve workspace source through tsx while
78
+ // the published package resolves lib/. Register both when source exists.
79
+ const packageRoot = dirname(dirname(resolved))
80
+ candidates.push(join(packageRoot, 'src', 'index.ts'))
81
+ candidates.push(join(packageRoot, 'src', 'index.js'))
82
+ let registered = 0
83
+ for (const candidate of candidates) {
84
+ let physical: string
85
+ try { physical = realpathSync(candidate) } catch { continue }
86
+ if (registeredPaths.has(physical)) continue
87
+ registeredPaths.add(physical)
88
+ try {
89
+ // DSH publishes ESM builds. Dynamic import also handles a CJS build and
90
+ // preserves the actual module instance selected by the host anchor.
91
+ const module = await import(pathToFileURL(physical).href) as KnownEventTypesModule
92
+ if (module.KNOWN_SESSION_EVENT_TYPES?.add) {
93
+ module.KNOWN_SESSION_EVENT_TYPES.add(QQCHAT_SESSION_EVENT_TYPE)
94
+ registered += 1
95
+ }
96
+ } catch {
97
+ // A published package may not ship its source entry; the lib entry above
98
+ // remains the normal registration target.
99
+ }
100
+ }
101
+ return registered
102
+ }