dsh-qqbot 1.0.0 → 1.0.1

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.md CHANGED
@@ -7,26 +7,40 @@ QQ 官方机器人插件,作为 DeepSeek Harness 的**树外插件**维护(
7
7
 
8
8
  ## 安装
9
9
 
10
- 构建,然后装进你运行的 profile
10
+ npm 装进你运行的 profile(无需本地构建):
11
11
 
12
12
  ```sh
13
- pnpm install
14
- pnpm run build
15
- pnpm dsh plugin --profile <name> add -w link:E:/Project/dsh-plugins/dsh-qqbot
13
+ dsh plugin --profile <name> add -w dsh-qqbot # 最新版
14
+ dsh plugin --profile <name> add -w dsh-qqbot@1.0.0 # 或固定版本
16
15
  ```
17
16
 
18
- 或者手动进入 profile 目录执行等价的 pnpm 命令:
17
+ 等价的手动方式(进入 profile 目录执行 pnpm):
19
18
 
20
19
  ```sh
21
20
  cd "$env:DSH_HOME/profiles/<name>" # DSH_HOME 未设置时是 ~/.dsh/profiles/<name>
22
- pnpm add -w link:E:/Project/dsh-plugins/dsh-qqbot
21
+ pnpm add -w dsh-qqbot
23
22
  ```
24
23
 
25
24
  注意:
26
25
 
27
26
  - **必须带 `-w`**:profile 目录自带 `pnpm-workspace.yaml`,不带 `-w` 时 pnpm 报 `ERR_PNPM_ADDING_TO_ROOT` 直接失败。
28
- - **用 `link:` 而不是裸路径**:裸路径是 `file:` 语义(快照副本),改源码不生效;`link:` 直接指向源码目录,重新 `pnpm run build` 后即时生效,无需重新 add。
29
27
  - 本包没有 `dsh.bundle` 声明,不会进入 profile 的 bundles 层,这符合预期——由下面的 patch 行按包名挂载;client 半由 `dsh.client` 扫描同一条挂载行自动进浏览器。
28
+ - 升级到新版本:重新 `add` 一次新版本号,然后重启 dsh。registry 版是安装时的快照(不再指向源码目录),改本地源码不影响它。
29
+
30
+ ### 本地开发(link 到源码)
31
+
32
+ 改插件源码时用 `link:` 直接指向源码目录:
33
+
34
+ ```sh
35
+ cd E:\Project\dsh-plugins\dsh-qqbot
36
+ pnpm install
37
+ pnpm run build
38
+ dsh plugin --profile <name> add -w link:E:/Project/dsh-plugins/dsh-qqbot
39
+ ```
40
+
41
+ - **用 `link:` 而不是裸路径**:裸路径是 `file:` 语义(快照副本),改源码不生效;`link:` 直接指向源码目录,重新 `pnpm run build` 后即时生效,无需重新 add。
42
+ - 改完发布版后切回本地版用同一条 `link:` 命令(`pnpm add` 会把依赖 spec 从 registry 改写成 link)。
43
+ - 反之,切回发布版:`dsh plugin --profile <name> add -w dsh-qqbot@<版本>`。
30
44
 
31
45
  在 **profile 自己的** `cordis.patch.yml`(`$DSH_HOME/profiles/<name>/cordis.patch.yml`)挂载(凭据在设置界面填写,不再写在这里):
32
46
 
@@ -44,13 +58,14 @@ pnpm add -w link:E:/Project/dsh-plugins/dsh-qqbot
44
58
 
45
59
  | 字段 | 说明 |
46
60
  | --- | --- |
47
- | AppID | QQ 开放平台机器人应用的 AppID |
48
- | AppSecret | **写穿不回显**:保存后输入框留空表示「保持现有密钥」;页面显示「已配置」,可用「清除密钥」移除。 |
49
- | 使用沙箱环境 | 勾选后走沙箱网关(`sandbox.api.sgroup.qq.com`),用于开发调试。 |
61
+ | AppID | QQ 开放平台机器人应用的 AppID。**写穿不回显**:页面只显示「已配置/未配置」状态,输入框填写新值以覆盖;「清除 AppID」可移除。 |
62
+ | AppSecret | **写穿不回显**:页面只显示「已配置/未配置」,留空表示保持现有密钥;「清除密钥」可移除。 |
63
+
64
+ 点击「保存」后**立即生效**:host 半按需从 credential 服务读取(`credentials/updated` 提交即触发),已连接的网关会用新身份自动重连,凭据被清空则自动断开。
50
65
 
51
- 点击「保存」后**立即生效**:host 半通过 settings 服务热读取(`settings/updated` 提交即触发),已连接的网关会用新身份自动重连,凭据被清空则自动断开。
66
+ 存储位置:`$DSH_HOME/.credentials.yaml`(明文,等价于环境变量;由 harness 的 credential 服务管理)。页面写入的引用名是 **`QQBOT_CRED_APP_ID` / `QQBOT_CRED_APP_SECRET`**——故意与 `QQBOT_APP_ID`/`QQBOT_APP_SECRET` 环境变量不同名:credential 服务的环境层会遮蔽同名引用并使其只读,shell 里一旦导出过 `QQBOT_APP_ID`,页面就会永远变灰,因此设置页用自己的引用名,UI 写入永远优先于环境变量。之所以走 credential 服务而不是 settings 命名空间:harness 的 settings wire 面只服务硬编码的命名空间白名单(`agent-loop`、`shell` 等),树外插件注册的命名空间不会被暴露;credential 面接受任意引用名,无需改 harness。
52
67
 
53
- 存储位置:`$DSH_HOME/settings.yaml` 的 `qqbot:` 段(明文,等价于环境变量)。配置解析顺序:**settings 用户层 → cordis.yml 行的 config 基础层 → 环境变量 `QQBOT_APP_ID` / `QQBOT_APP_SECRET`**(无头环境可用环境变量兜底)。三处都没有时插件照常挂载,`/qqbot on` 会报错并指向设置页。
68
+ 配置解析顺序:**credential 库(设置页写入)→ `settings.yaml` 的 `qqbot:` → cordis.yml 行的 config 基础层 → 环境变量 `QQBOT_APP_ID` / `QQBOT_APP_SECRET`**(无头环境可用环境变量兜底)。**沙箱开关**不在此页:仅在 cordis.yml 的 `config.sandbox: true` 里配置。四处都没有 AppID/AppSecret 时插件照常挂载,`/qqbot on` 会报错并指向设置页。
54
69
 
55
70
  ## 测试
56
71
 
@@ -58,7 +73,7 @@ pnpm add -w link:E:/Project/dsh-plugins/dsh-qqbot
58
73
  pnpm test
59
74
  ```
60
75
 
61
- host 的 `protocol` / `gateway` / `qqbot` / `settings-controller` 四套用例可直接跑(57 例,settings-controller 通过 `vi.mock` 替换 runtime 客户端模块以绕过浏览器 bundle)。`tests/*.client.spec.ts(x)` 被 vitest 排除:它们 import `@deepseek-ai/dsh-client-runtime/client`,而 registry 上发布的是浏览器 bundle(`lib/client.js`,import 时执行 `window.__ModuleLoader__.load(...)`),node/jsdom 无法加载。这些用例在 harness 源码树内开发期已验证,客户端开关与设置页的验证走浏览器手动确认(见「使用」)。
76
+ host 的 `protocol` / `gateway` / `qqbot` / `settings-controller` 四套用例可直接跑(59 例,settings-controller 通过 `vi.mock` 替换 runtime 客户端模块以绕过浏览器 bundle)。`tests/*.client.spec.ts(x)` 被 vitest 排除:它们 import `@deepseek-ai/dsh-client-runtime/client`,而 registry 上发布的是浏览器 bundle(`lib/client.js`,import 时执行 `window.__ModuleLoader__.load(...)`),node/jsdom 无法加载。这些用例在 harness 源码树内开发期已验证,客户端开关与设置页的验证走浏览器手动确认(见「使用」)。
62
77
 
63
78
  ## 使用
64
79
 
@@ -70,16 +85,18 @@ host 的 `protocol` / `gateway` / `qqbot` / `settings-controller` 四套用例
70
85
 
71
86
  ## peer 依赖解析
72
87
 
73
- `link:` 安装的 symlink 目标在 harness 之外,Node 从真实路径(realpath)向上解析依赖,所以 `@deepseek-ai/cordis`、`dsh-*` Service Definition 这些 peer 命中 **dsh-qqbot 项目自己的 `node_modules`**(`pnpm install` 装的最新 registry 版),不是 profile 的 `node_modules` 或 `$DSH_HOME/profiles/node_modules` 回退。这带来两个后果:
88
+ 两种安装方式的依赖解析不同:
74
89
 
75
- - 插件与 harness 各持一份 `@deepseek-ai/cordis`(双实例)。当前启动与挂载正常,但若插件代码对 `ctx` 做跨实例判断(`instanceof` 等)会失效。
76
- - peer 版本按 `^` 取最新(例如 `dsh-session@0.1.0-rc.6`),可能比正在运行的 harness(rc.5)新。运行时服务走 harness 注入的 ctx,一般兼容;harness 升级或插件用到新 API 时需对齐 peer 版本。
90
+ - **registry 版(推荐)**:包从 profile 的 `node_modules` 向上解析 peer(`@deepseek-ai/cordis`、`dsh-*` Service Definition 等),与 harness 及 profile 的 `$DSH_HOME/profiles/node_modules` 回退**共享同一份实例**,没有双实例问题。
91
+ - **`link:` 开发版**:symlink 目标在 harness 之外,Node 从真实路径(realpath)向上解析,peer 命中 **dsh-qqbot 项目自己的 `node_modules`**(`pnpm install` 装的最新 registry 版)。这带来两个后果:
92
+ - 插件与 harness 各持一份 `@deepseek-ai/cordis`(双实例)。当前启动与挂载正常,但若插件代码对 `ctx` 做跨实例判断(`instanceof` 等)会失效。
93
+ - peer 版本按 `^` 取最新(例如 `dsh-session@0.1.0-rc.6`),可能比正在运行的 harness(rc.5)新。运行时服务走 harness 注入的 ctx,一般兼容;harness 升级或插件用到新 API 时需对齐 peer 版本。
77
94
 
78
95
  ## 版本耦合说明
79
96
 
80
97
  client 半的浏览器 bundle 必须用 harness 的客户端加载协议构建:`tsdown.config.ts` 里硬编码了 `PLATFORM_MODULES`(模块表 external 列表,含 `@deepseek-ai/dsh-client-runtime/client` 的 store 豁免)和 `__ModuleLoader__.load({ id })` 的包名 id。如果 harness 更新了这份平台模块列表或 `__ModuleLoader__` 协议,需要同步更新 `tsdown.config.ts`,否则浏览器端加载会失败。
81
98
 
82
- 设置页依赖 harness settings 能力(host 的 `settings` 服务 + `settings.section` 槽位 + `api.settings` wire 面),这些都在 web 基础组合里,无需额外挂载行;如果换成自定义 profile,需保证 `@deepseek-ai/dsh-settings-file` `@deepseek-ai/dsh-client-ui-settings(-general)` 在场。
99
+ 设置页依赖 harness 的:credential 能力(host 的 `credentials` 服务 + `api.credentials` wire 面,由 base 组合的 `@deepseek-ai/dsh-credentials-local` 提供)和 settings 面板(`settings.section` 槽位 + settings 服务,用于 `qqbot:` 段的 base 层解析),这些都在 web 基础组合里,无需额外挂载行;如果换成自定义 profile,需保证上述行在场。
83
100
 
84
101
  ## 发布
85
102
 
package/lib/client.js CHANGED
@@ -8,54 +8,47 @@ window.__ModuleLoader__.load({
8
8
  let react = require("react");
9
9
  let react_jsx_runtime = require("react/jsx-runtime");
10
10
  //#region src/client/settings-controller.ts
11
- /** The settings namespace the Host plugin registers its identity under. */
12
- const QQBOT_SETTINGS_NAMESPACE = "qqbot";
11
+ /**
12
+ * Credential reference holding the robot application id. Deliberately NOT the
13
+ * environment-variable name: the credential store's env layer shadows a
14
+ * same-named ref and makes it read-only, which would disable the settings
15
+ * page wherever `QQBOT_APP_ID` is exported.
16
+ */
17
+ const QQBOT_APP_ID_REF = "QQBOT_CRED_APP_ID";
18
+ /**
19
+ * Credential reference holding the robot application secret; distinct from
20
+ * `QQBOT_APP_SECRET` for the same shadowing reason.
21
+ */
22
+ const QQBOT_APP_SECRET_REF = "QQBOT_CRED_APP_SECRET";
13
23
  /** Human text for a rejected wire call or transport failure. */
14
24
  function messageOf(error) {
15
25
  return error instanceof Error ? error.message : String(error);
16
26
  }
17
- /** Read the redacted resolved AppID, defaulting absent values to ''. */
18
- function appIdIn(view) {
19
- if (typeof view.value !== "object" || view.value === null) return "";
20
- const value = view.value["appId"];
21
- return typeof value === "string" ? value : "";
22
- }
23
- /** Read the redacted resolved sandbox flag, defaulting to false. */
24
- function sandboxIn(view) {
25
- if (typeof view.value !== "object" || view.value === null) return false;
26
- return view.value["sandbox"] === true;
27
- }
28
- /** Whether the redacted view reports a stored AppSecret. */
29
- function secretSetIn(view) {
30
- return view.secrets.some((secret) => secret.path.length === 1 && secret.path[0] === "appSecret" && secret.set);
31
- }
32
27
  /**
33
- * Serializes the qqbot namespace's Host reads and writes behind one snapshot
34
- * store. Latest load wins; a refused save reloads the namespace so the form
35
- * re-renders from the fresh view and the user can re-apply the edit.
28
+ * Serializes the two credential references' Host reads and writes behind one
29
+ * snapshot store. Latest load wins; a refused save reloads the views so the
30
+ * form re-renders from the fresh state and the user can re-apply the edit.
36
31
  */
37
32
  var QqbotSettingsController = class {
38
33
  api;
39
34
  /** uSES-safe state source the section renders through its hooks compartment. */
40
35
  store = (0, _deepseek_ai_dsh_client_runtime_client.createSnapshotStore)({
41
36
  status: "idle",
42
- appId: "",
43
- sandbox: false,
37
+ appIdSet: false,
44
38
  secretSet: false,
45
39
  writable: false,
46
- revision: void 0,
47
40
  error: null
48
41
  });
49
42
  generation = 0;
50
43
  /**
51
- * @param api - the settings wire face.
44
+ * @param api - the credentials wire face.
52
45
  */
53
46
  constructor(api) {
54
47
  this.api = api;
55
48
  }
56
49
  /**
57
- * Refresh the snapshot from `settings.describe`; a failure keeps the last
58
- * good state and surfaces the error.
50
+ * Refresh the snapshot from `credentials.describe`; a failure keeps the
51
+ * last good state and surfaces the error.
59
52
  * @returns settlement after the read.
60
53
  */
61
54
  async load() {
@@ -65,12 +58,20 @@ window.__ModuleLoader__.load({
65
58
  state.error = null;
66
59
  });
67
60
  try {
68
- const response = await this.api.settings.describe({});
61
+ const response = await this.api.credentials.describe({ refs: [QQBOT_APP_ID_REF, QQBOT_APP_SECRET_REF] });
69
62
  if (!response.result.ok) throw new Error(response.result.error.message);
70
- const view = response.result.value.namespaces.find((candidate) => candidate.ns === QQBOT_SETTINGS_NAMESPACE);
71
- if (view === void 0) throw new Error(`settings namespace "${QQBOT_SETTINGS_NAMESPACE}" is not registered`);
63
+ const views = response.result.value.credentials;
64
+ const appIdView = views[QQBOT_APP_ID_REF];
65
+ const secretView = views[QQBOT_APP_SECRET_REF];
66
+ if (appIdView === void 0 || secretView === void 0) throw new Error("credentials domain did not answer the qqbot references");
72
67
  if (generation !== this.generation) return;
73
- this.accept(view, response.result.value.writable);
68
+ this.store.update((state) => {
69
+ state.status = "ready";
70
+ state.appIdSet = appIdView.configured;
71
+ state.secretSet = secretView.configured;
72
+ state.writable = appIdView.writable && secretView.writable;
73
+ state.error = null;
74
+ });
74
75
  } catch (error) {
75
76
  if (generation !== this.generation) return;
76
77
  this.store.update((state) => {
@@ -80,67 +81,41 @@ window.__ModuleLoader__.load({
80
81
  }
81
82
  }
82
83
  /**
83
- * Apply one user edit as ordered path ops; a refusal reloads the namespace
84
- * and returns its message.
84
+ * Apply one user edit as ordered credential writes, stopping at the first
85
+ * refusal; a refusal reloads the views and returns its message.
85
86
  * @param patch - the fields the user changed.
86
87
  * @returns null on success; the Host's failure text otherwise.
87
88
  */
88
89
  async save(patch) {
89
- const ops = [];
90
- if (patch.appId !== void 0) ops.push(patch.appId.length === 0 ? {
91
- op: "unset",
92
- path: ["appId"]
93
- } : {
94
- op: "set",
95
- path: ["appId"],
96
- value: patch.appId
97
- });
98
- if (patch.sandbox !== void 0) ops.push({
99
- op: "set",
100
- path: ["sandbox"],
101
- value: patch.sandbox
102
- });
103
- if (patch.clearSecret === true) ops.push({
104
- op: "unset",
105
- path: ["appSecret"]
106
- });
107
- if (patch.appSecret !== void 0 && patch.appSecret.length > 0) ops.push({
108
- op: "set",
109
- path: ["appSecret"],
110
- value: patch.appSecret
111
- });
112
- if (ops.length === 0) return null;
113
- const revision = this.store.getSnapshot().revision;
114
- let response;
90
+ const writes = [];
91
+ if (patch.clearAppId === true) writes.push(() => this.api.credentials.unset({ ref: QQBOT_APP_ID_REF }));
92
+ const appId = patch.appId;
93
+ if (appId !== void 0 && appId.length > 0) writes.push(() => this.api.credentials.set({
94
+ ref: QQBOT_APP_ID_REF,
95
+ value: appId
96
+ }));
97
+ if (patch.clearSecret === true) writes.push(() => this.api.credentials.unset({ ref: QQBOT_APP_SECRET_REF }));
98
+ const appSecret = patch.appSecret;
99
+ if (appSecret !== void 0 && appSecret.length > 0) writes.push(() => this.api.credentials.set({
100
+ ref: QQBOT_APP_SECRET_REF,
101
+ value: appSecret
102
+ }));
103
+ if (writes.length === 0) return null;
115
104
  try {
116
- response = await this.api.settings.mutate({
117
- ns: QQBOT_SETTINGS_NAMESPACE,
118
- ops,
119
- ...revision === void 0 ? {} : { expectedRevision: revision }
120
- });
105
+ for (const write of writes) {
106
+ const response = await write();
107
+ if (!response.result.ok) {
108
+ await this.load();
109
+ return response.result.error.message;
110
+ }
111
+ }
121
112
  } catch (error) {
122
113
  await this.load();
123
114
  return messageOf(error);
124
115
  }
125
- if (!response.result.ok) {
126
- await this.load();
127
- return response.result.error.message;
128
- }
129
- this.accept(response.result.value, this.store.getSnapshot().writable);
116
+ await this.load();
130
117
  return null;
131
118
  }
132
- /** Fold one redacted wire view into the snapshot. */
133
- accept(view, writable) {
134
- this.store.update((state) => {
135
- state.status = "ready";
136
- state.appId = appIdIn(view);
137
- state.sandbox = sandboxIn(view);
138
- state.secretSet = secretSetIn(view);
139
- state.writable = writable;
140
- state.revision = view.revision;
141
- state.error = null;
142
- });
143
- }
144
119
  };
145
120
  /**
146
121
  * Refresh only after the section left idle, so an unopened page never fetches
@@ -153,7 +128,7 @@ window.__ModuleLoader__.load({
153
128
  }
154
129
  //#endregion
155
130
  //#region \0dsh-css:E:\Project\dsh-plugins\dsh-qqbot\src\client\QqbotSettingsSection.module.css.mjs
156
- const css$1 = ".TV8fAG_section{flex-direction:column;gap:12px;width:100%;padding-bottom:8px;display:flex}.TV8fAG_title{color:var(--dsw-alias-label-primary);margin:0;font-size:16px;font-weight:600;line-height:24px}.TV8fAG_desc{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px;line-height:20px}.TV8fAG_field{flex-direction:column;gap:6px;display:flex}.TV8fAG_fieldLabel{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px}.TV8fAG_input{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);width:100%;max-width:360px;height:32px;font:inherit;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:14px;line-height:22px}.TV8fAG_input:focus{border-color:var(--dsw-alias-brand-primary);outline:none}.TV8fAG_input::placeholder{color:var(--dsw-alias-label-dimmed)}.TV8fAG_input:disabled{opacity:.6;cursor:default}.TV8fAG_fieldHint{color:var(--dsw-alias-label-dimmed);margin:0;font-size:12px;line-height:18px}.TV8fAG_secretRow{align-items:center;gap:8px;width:100%;max-width:360px;display:flex}.TV8fAG_secretRow .TV8fAG_input{flex:auto}.TV8fAG_clearButton{border:1px solid var(--dsw-alias-border-l2);height:28px;font:inherit;color:var(--dsw-alias-state-error-primary);cursor:pointer;background:0 0;border-radius:8px;flex:none;padding:0 10px;font-size:13px;line-height:18px}.TV8fAG_clearButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.TV8fAG_clearButton:disabled{opacity:.6;cursor:default}.TV8fAG_fieldCheck{width:fit-content;color:var(--dsw-alias-label-primary);cursor:pointer;align-items:center;gap:8px;font-size:13px;line-height:20px;display:flex}.TV8fAG_fieldCheck input:disabled{cursor:default}.TV8fAG_actions{align-items:center;gap:12px;display:flex}.TV8fAG_saveButton{height:32px;font:inherit;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary-invert);cursor:pointer;border:none;border-radius:8px;padding:0 16px;font-size:14px;font-weight:500;line-height:20px}.TV8fAG_saveButton:hover:not(:disabled){opacity:.9}.TV8fAG_saveButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.TV8fAG_saveButton:disabled{opacity:.5;cursor:default}.TV8fAG_bound{color:var(--dsw-alias-state-warn-label);font-size:12px;line-height:18px}.TV8fAG_messageOk{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:18px}.TV8fAG_messageError{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px;line-height:18px}";
131
+ const css$1 = ".TV8fAG_section{flex-direction:column;gap:12px;width:100%;padding-bottom:8px;display:flex}.TV8fAG_title{color:var(--dsw-alias-label-primary);margin:0;font-size:16px;font-weight:600;line-height:24px}.TV8fAG_desc{color:var(--dsw-alias-label-secondary);margin:0;font-size:13px;line-height:20px}.TV8fAG_field{flex-direction:column;gap:6px;display:flex}.TV8fAG_fieldLabel{color:var(--dsw-alias-label-primary);align-items:center;gap:8px;font-size:13px;font-weight:500;line-height:20px;display:flex}.TV8fAG_chipSet{background:var(--dsw-alias-state-warn-tertiary);height:18px;color:var(--dsw-alias-state-warn-label);border-radius:999px;align-items:center;padding:0 6px;font-size:11px;font-weight:500;line-height:18px;display:inline-flex}.TV8fAG_chipUnset{background:var(--dsw-alias-bg-layer-1);height:18px;color:var(--dsw-alias-label-dimmed);border-radius:999px;align-items:center;padding:0 6px;font-size:11px;font-weight:500;line-height:18px;display:inline-flex}.TV8fAG_input{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);width:100%;max-width:360px;height:32px;font:inherit;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:14px;line-height:22px}.TV8fAG_input:focus{border-color:var(--dsw-alias-brand-primary);outline:none}.TV8fAG_input::placeholder{color:var(--dsw-alias-label-dimmed)}.TV8fAG_input:disabled{opacity:.6;cursor:default}.TV8fAG_fieldHint{color:var(--dsw-alias-label-dimmed);margin:0;font-size:12px;line-height:18px}.TV8fAG_secretRow{align-items:center;gap:8px;width:100%;max-width:360px;display:flex}.TV8fAG_secretRow .TV8fAG_input{flex:auto}.TV8fAG_clearButton{border:1px solid var(--dsw-alias-border-l2);height:28px;font:inherit;color:var(--dsw-alias-state-error-primary);cursor:pointer;background:0 0;border-radius:8px;flex:none;padding:0 10px;font-size:13px;line-height:18px}.TV8fAG_clearButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}.TV8fAG_clearButton:disabled{opacity:.6;cursor:default}.TV8fAG_actions{align-items:center;gap:12px;display:flex}.TV8fAG_saveButton{height:32px;font:inherit;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);cursor:pointer;border:none;border-radius:8px;padding:0 16px;font-size:14px;font-weight:500;line-height:20px}.TV8fAG_saveButton:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.TV8fAG_saveButton:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}.TV8fAG_saveButton:disabled{opacity:.5;cursor:default}.TV8fAG_bound{color:var(--dsw-alias-state-warn-label);font-size:12px;line-height:18px}.TV8fAG_messageOk{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:18px}.TV8fAG_messageError{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px;line-height:18px}";
157
132
  const tagId$1 = "dsh-qqbot/QqbotSettingsSection.module.css";
158
133
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$1) + "]") === null) {
159
134
  const tag = document.createElement("style");
@@ -163,67 +138,61 @@ window.__ModuleLoader__.load({
163
138
  document.head.appendChild(tag);
164
139
  }
165
140
  var QqbotSettingsSection_module_css_default = {
166
- "actions": "TV8fAG_actions",
167
- "fieldLabel": "TV8fAG_fieldLabel",
141
+ "messageOk": "TV8fAG_messageOk",
168
142
  "bound": "TV8fAG_bound",
169
- "title": "TV8fAG_title",
170
- "messageError": "TV8fAG_messageError",
171
- "input": "TV8fAG_input",
172
- "fieldCheck": "TV8fAG_fieldCheck",
173
- "secretRow": "TV8fAG_secretRow",
174
- "desc": "TV8fAG_desc",
175
143
  "field": "TV8fAG_field",
144
+ "chipUnset": "TV8fAG_chipUnset",
145
+ "section": "TV8fAG_section",
146
+ "desc": "TV8fAG_desc",
147
+ "messageError": "TV8fAG_messageError",
148
+ "saveButton": "TV8fAG_saveButton",
176
149
  "fieldHint": "TV8fAG_fieldHint",
177
150
  "clearButton": "TV8fAG_clearButton",
178
- "saveButton": "TV8fAG_saveButton",
179
- "section": "TV8fAG_section",
180
- "messageOk": "TV8fAG_messageOk"
151
+ "title": "TV8fAG_title",
152
+ "fieldLabel": "TV8fAG_fieldLabel",
153
+ "chipSet": "TV8fAG_chipSet",
154
+ "actions": "TV8fAG_actions",
155
+ "input": "TV8fAG_input",
156
+ "secretRow": "TV8fAG_secretRow"
181
157
  };
182
158
  //#endregion
183
159
  //#region src/client/QqbotSettingsSection.tsx
184
160
  /**
185
- * The QQ Bot settings page: AppID, AppSecret, and sandbox fields over the
186
- * Host's redacted namespace view. The AppSecret is write-only on the wire, so
187
- * the field renders blank and "keep unchanged" is the empty-value meaning;
188
- * the clear button removes a stored secret. Saving applies every changed
189
- * field in one ordered write, and the Host restarts a live gateway in place.
161
+ * The QQ Bot settings page: AppID and AppSecret over the credential seam's
162
+ * value-free views. Both values are write-only on the wire — the page shows
163
+ * configured/not-configured chips and the inputs mean "enter a new value";
164
+ * an empty field keeps the stored one, and the clear buttons remove it.
165
+ * Saving applies every changed field in one ordered write, and the Host
166
+ * restarts a live gateway in place.
190
167
  */
191
168
  function QqbotSettingsSection({ useQqbotSettings, useQqbotBound, save, t }) {
192
169
  const snapshot = useQqbotSettings((state) => state);
193
170
  const boundSessionId = useQqbotBound((state) => state.sessionId);
194
171
  const [appId, setAppId] = (0, react.useState)("");
195
172
  const [secret, setSecret] = (0, react.useState)("");
196
- const [sandbox, setSandbox] = (0, react.useState)(false);
197
173
  const [busy, setBusy] = (0, react.useState)(false);
198
174
  const [message, setMessage] = (0, react.useState)(null);
199
- const revision = snapshot.status === "ready" ? snapshot.revision : void 0;
200
- (0, react.useEffect)(() => {
201
- if (snapshot.status !== "ready") return;
202
- setAppId(snapshot.appId);
203
- setSecret("");
204
- setSandbox(snapshot.sandbox);
205
- }, [revision]);
206
175
  const ready = snapshot.status === "ready";
207
- const dirty = ready && (appId !== snapshot.appId || secret.length > 0 || sandbox !== snapshot.sandbox);
176
+ const dirty = ready && (appId.length > 0 || secret.length > 0);
208
177
  const finish = (failure) => {
209
178
  setBusy(false);
210
- setMessage(failure === null ? {
211
- kind: "ok",
212
- text: t("save.ok")
213
- } : {
179
+ if (failure === null) {
180
+ setAppId("");
181
+ setSecret("");
182
+ setMessage({
183
+ kind: "ok",
184
+ text: t("save.ok")
185
+ });
186
+ } else setMessage({
214
187
  kind: "error",
215
188
  text: failure
216
189
  });
217
190
  };
218
- const onClickSave = () => {
219
- if (busy || !ready || !snapshot.writable || !dirty) return;
191
+ const runSave = (patch) => {
192
+ if (busy || !ready || !snapshot.writable) return;
220
193
  setBusy(true);
221
194
  setMessage(null);
222
- save({
223
- appId,
224
- ...secret.length > 0 ? { appSecret: secret } : {},
225
- sandbox
226
- }).then(finish, (error) => {
195
+ save(patch).then(finish, (error) => {
227
196
  setBusy(false);
228
197
  setMessage({
229
198
  kind: "error",
@@ -231,20 +200,25 @@ window.__ModuleLoader__.load({
231
200
  });
232
201
  });
233
202
  };
203
+ const onClickSave = () => {
204
+ if (!dirty) return;
205
+ runSave({
206
+ ...appId.length > 0 ? { appId } : {},
207
+ ...secret.length > 0 ? { appSecret: secret } : {}
208
+ });
209
+ };
210
+ const onClickClearAppId = () => {
211
+ runSave({
212
+ ...appId.length > 0 ? { appId } : {},
213
+ ...secret.length > 0 ? { appSecret: secret } : {},
214
+ clearAppId: true
215
+ });
216
+ };
234
217
  const onClickClearSecret = () => {
235
- if (busy || !ready || !snapshot.writable) return;
236
- setBusy(true);
237
- setMessage(null);
238
- save({
239
- appId,
240
- sandbox,
218
+ runSave({
219
+ ...appId.length > 0 ? { appId } : {},
220
+ ...secret.length > 0 ? { appSecret: secret } : {},
241
221
  clearSecret: true
242
- }).then(finish, (error) => {
243
- setBusy(false);
244
- setMessage({
245
- kind: "error",
246
- text: error instanceof Error ? error.message : String(error)
247
- });
248
222
  });
249
223
  };
250
224
  let statusLine = null;
@@ -265,20 +239,32 @@ window.__ModuleLoader__.load({
265
239
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
266
240
  className: QqbotSettingsSection_module_css_default.field,
267
241
  children: [
268
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
242
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
269
243
  className: QqbotSettingsSection_module_css_default.fieldLabel,
270
- children: t("appId.label")
244
+ children: [t("appId.label"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
245
+ className: snapshot.appIdSet ? QqbotSettingsSection_module_css_default.chipSet : QqbotSettingsSection_module_css_default.chipUnset,
246
+ children: snapshot.appIdSet ? t("chip.set") : t("chip.unset")
247
+ })]
271
248
  }),
272
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
273
- className: QqbotSettingsSection_module_css_default.input,
274
- value: appId,
275
- onChange: (event) => {
276
- setAppId(event.target.value);
277
- },
278
- placeholder: t("appId.placeholder"),
279
- autoComplete: "off",
280
- spellCheck: false,
281
- disabled: !ready || !snapshot.writable
249
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
250
+ className: QqbotSettingsSection_module_css_default.secretRow,
251
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
252
+ className: QqbotSettingsSection_module_css_default.input,
253
+ value: appId,
254
+ onChange: (event) => {
255
+ setAppId(event.target.value);
256
+ },
257
+ placeholder: snapshot.appIdSet ? t("appId.placeholder.set") : t("appId.placeholder"),
258
+ autoComplete: "off",
259
+ spellCheck: false,
260
+ disabled: !ready || !snapshot.writable
261
+ }), snapshot.appIdSet ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
262
+ type: "button",
263
+ className: QqbotSettingsSection_module_css_default.clearButton,
264
+ onClick: onClickClearAppId,
265
+ disabled: busy,
266
+ children: t("clear.appId")
267
+ }) : null]
282
268
  }),
283
269
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
284
270
  className: QqbotSettingsSection_module_css_default.fieldHint,
@@ -289,9 +275,12 @@ window.__ModuleLoader__.load({
289
275
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
290
276
  className: QqbotSettingsSection_module_css_default.field,
291
277
  children: [
292
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
278
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
293
279
  className: QqbotSettingsSection_module_css_default.fieldLabel,
294
- children: t("appSecret.label")
280
+ children: [t("appSecret.label"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
281
+ className: snapshot.secretSet ? QqbotSettingsSection_module_css_default.chipSet : QqbotSettingsSection_module_css_default.chipUnset,
282
+ children: snapshot.secretSet ? t("chip.set") : t("chip.unset")
283
+ })]
295
284
  }),
296
285
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
297
286
  className: QqbotSettingsSection_module_css_default.secretRow,
@@ -310,7 +299,7 @@ window.__ModuleLoader__.load({
310
299
  className: QqbotSettingsSection_module_css_default.clearButton,
311
300
  onClick: onClickClearSecret,
312
301
  disabled: busy,
313
- children: t("clear.label")
302
+ children: t("clear.secret")
314
303
  }) : null]
315
304
  }),
316
305
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -319,21 +308,6 @@ window.__ModuleLoader__.load({
319
308
  })
320
309
  ]
321
310
  }),
322
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
323
- className: QqbotSettingsSection_module_css_default.fieldCheck,
324
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
325
- type: "checkbox",
326
- checked: sandbox,
327
- onChange: (event) => {
328
- setSandbox(event.target.checked);
329
- },
330
- disabled: !ready || !snapshot.writable
331
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sandbox.label") })]
332
- }),
333
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
334
- className: QqbotSettingsSection_module_css_default.fieldHint,
335
- children: t("sandbox.hint")
336
- }),
337
311
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
338
312
  className: QqbotSettingsSection_module_css_default.actions,
339
313
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -416,25 +390,27 @@ window.__ModuleLoader__.load({
416
390
  "toggle.off.label": "连接 QQ",
417
391
  "settings.nav": "QQ Bot",
418
392
  "settings.title": "QQ 官方机器人",
419
- "settings.desc": "填写 QQ 开放平台机器人应用的 AppID 与 AppSecret 后,在会话中使用 /qqbot on 或输入框的「连接 QQ」开关接入。配置保存后立即生效。",
393
+ "settings.desc": "填写 QQ 开放平台机器人应用的 AppID 与 AppSecret 后,在会话中使用 /qqbot on 或输入框的「连接 QQ」开关接入。两者保存后都不回显,页面仅显示配置状态;保存立即生效。",
394
+ "chip.set": "已配置",
395
+ "chip.unset": "未配置",
420
396
  "appId.label": "AppID",
421
- "appId.placeholder": "机器人的 AppID",
422
- "appId.hint": "QQ 开放平台机器人应用的 AppID。",
397
+ "appId.placeholder": "输入新的 AppID",
398
+ "appId.placeholder.set": "已配置(不回显),输入以覆盖",
399
+ "appId.hint": "QQ 开放平台机器人应用的 AppID;留空表示保持现有值。",
400
+ "clear.appId": "清除 AppID",
423
401
  "appSecret.label": "AppSecret",
424
402
  "appSecret.placeholder": "输入新的 AppSecret",
425
- "appSecret.placeholder.set": "已保存(不回显),输入以覆盖",
403
+ "appSecret.placeholder.set": "已配置(不回显),输入以覆盖",
426
404
  "appSecret.hint": "保存后不回显;留空表示保持现有密钥。",
427
405
  "appSecret.hint.set": "已配置 AppSecret;留空保存将保持不变,点击「清除密钥」可移除。",
428
- "clear.label": "清除密钥",
429
- "sandbox.label": "使用沙箱环境",
430
- "sandbox.hint": "启用后连接沙箱网关(sandbox.api.sgroup.qq.com),用于开发调试。",
406
+ "clear.secret": "清除密钥",
431
407
  "save.label": "保存",
432
408
  "save.busy": "保存中…",
433
409
  "save.ok": "已保存,立即生效。",
434
410
  "bound.prefix": "已绑定会话:",
435
411
  "status.loading": "正在读取配置…",
436
412
  "status.error": "读取配置失败:",
437
- "status.readonly": "设置存储为只读,无法修改。"
413
+ "status.readonly": "凭据被只读来源(如环境变量)遮蔽,无法在界面修改。"
438
414
  };
439
415
  /** English dictionary, checked complete against the zh key set. */
440
416
  const en = {
@@ -446,25 +422,27 @@ window.__ModuleLoader__.load({
446
422
  "toggle.off.label": "Connect QQ",
447
423
  "settings.nav": "QQ Bot",
448
424
  "settings.title": "QQ Official Bot",
449
- "settings.desc": "Enter the AppID and AppSecret of your QQ open-platform bot application, then use /qqbot on or the \"Connect QQ\" toggle in a session. Saved changes apply immediately.",
425
+ "settings.desc": "Enter the AppID and AppSecret of your QQ open-platform bot application, then use /qqbot on or the \"Connect QQ\" toggle in a session. Both are write-only: the page shows only configured status. Saved changes apply immediately.",
426
+ "chip.set": "Configured",
427
+ "chip.unset": "Not configured",
450
428
  "appId.label": "AppID",
451
- "appId.placeholder": "Bot AppID",
452
- "appId.hint": "The AppID of the bot application on the QQ open platform.",
429
+ "appId.placeholder": "Enter a new AppID",
430
+ "appId.placeholder.set": "Configured (not shown back), enter to replace",
431
+ "appId.hint": "The AppID of the bot application on the QQ open platform; leave empty to keep the stored value.",
432
+ "clear.appId": "Clear AppID",
453
433
  "appSecret.label": "AppSecret",
454
434
  "appSecret.placeholder": "Enter a new AppSecret",
455
- "appSecret.placeholder.set": "Saved (not shown back), enter to replace",
435
+ "appSecret.placeholder.set": "Configured (not shown back), enter to replace",
456
436
  "appSecret.hint": "Never shown back after saving; leave empty to keep the stored value.",
457
437
  "appSecret.hint.set": "An AppSecret is configured; saving with an empty field keeps it. Use \"Clear secret\" to remove it.",
458
- "clear.label": "Clear secret",
459
- "sandbox.label": "Use the sandbox environment",
460
- "sandbox.hint": "Route token, gateway, and sends through the sandbox host (sandbox.api.sgroup.qq.com) for development.",
438
+ "clear.secret": "Clear secret",
461
439
  "save.label": "Save",
462
440
  "save.busy": "Saving…",
463
441
  "save.ok": "Saved, applied immediately.",
464
442
  "bound.prefix": "Bound session: ",
465
443
  "status.loading": "Loading configuration…",
466
444
  "status.error": "Failed to load configuration: ",
467
- "status.readonly": "Settings storage is read-only."
445
+ "status.readonly": "The credentials are shadowed by a read-only source (such as environment variables) and cannot be changed here."
468
446
  };
469
447
  //#endregion
470
448
  //#region src/client/index.ts
@@ -520,8 +498,8 @@ window.__ModuleLoader__.load({
520
498
  }, QqbotToggle));
521
499
  const settings = new QqbotSettingsController(ctx.get("connection").api);
522
500
  ctx.effect(() => {
523
- const disposers = [ctx.remote.$on("settings/document-updated", (ns) => {
524
- if (ns === "qqbot") refreshIfLoaded(settings);
501
+ const disposers = [ctx.remote.$on("credentials/updated", (ref) => {
502
+ if (ref === "QQBOT_CRED_APP_ID" || ref === "QQBOT_CRED_APP_SECRET") refreshIfLoaded(settings);
525
503
  }), ctx.on("connection/reset", () => {
526
504
  refreshIfLoaded(settings);
527
505
  })];
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import Schema from "@deepseek-ai/schemastery";
2
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
2
3
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
3
4
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
4
5
  //#region lib/types/protocol.js
@@ -435,10 +436,12 @@ var QqGatewayClient = class {
435
436
  * QQ gateway; `/qqbot off` disconnects. Nothing connects until a session opts
436
437
  * in, and QQ messages then arrive as ordinary user messages on that agent.
437
438
  *
438
- * The robot identity (AppID/AppSecret) lives in the `qqbot` settings section —
439
- * the Settings QQ Bot page of the web GUI — with the composition entry as
440
- * the base layer and `QQBOT_APP_ID`/`QQBOT_APP_SECRET` as an environment
441
- * fallback. Committed settings changes restart a live gateway in place.
439
+ * The robot identity (AppID/AppSecret) resolves in order: the credential
440
+ * references `QQBOT_APP_ID`/`QQBOT_APP_SECRET` (written by the Settings QQ
441
+ * Bot page into the harness credential store), then the `qqbot` settings
442
+ * section (whose base layer is the composition entry), then the same-named
443
+ * environment variables. Committed credential or settings changes restart a
444
+ * live gateway in place.
442
445
  *
443
446
  * The bridge is also an approval answerer for its bound agent: a tool call that
444
447
  * asks for confirmation is forwarded to the originating QQ chat as a prompt,
@@ -459,7 +462,11 @@ const Config = Schema.object({
459
462
  const QQBOT_APP_ID_ENV = "QQBOT_APP_ID";
460
463
  /** Environment variable fallback for the robot application secret. */
461
464
  const QQBOT_APP_SECRET_ENV = "QQBOT_APP_SECRET";
462
- /** Settings namespace carrying the robot identity (Settings → QQ Bot). */
465
+ /** Credential reference for the robot application id (written by Settings → QQ Bot). */
466
+ const QQBOT_APP_ID_REF = credentialRef("QQBOT_CRED_APP_ID");
467
+ /** Credential reference for the robot application secret (written by Settings → QQ Bot). */
468
+ const QQBOT_APP_SECRET_REF = credentialRef("QQBOT_CRED_APP_SECRET");
469
+ /** Settings namespace carrying the identity base layer (the cordis.yml config). */
463
470
  const QQBOT_SETTINGS_NAMESPACE = settingsNamespace("qqbot");
464
471
  /** Prompt section name registered on the bound agent's scope. */
465
472
  const QQBOT_CHANNEL_SECTION = "qqbot:channel";
@@ -531,28 +538,13 @@ function parseAnswer(text) {
531
538
  if (REJECT_ANSWERS.has(first)) return "rejected";
532
539
  }
533
540
  /**
534
- * Resolve robot credentials from a config section, then environment, then
535
- * fail loud.
536
- * @param config - the validated section (settings-resolved or composition entry).
537
- * @returns the concrete credentials.
538
- */
539
- function resolveCredentials(config) {
540
- const appId = config.appId ?? process.env["QQBOT_APP_ID"];
541
- const appSecret = config.appSecret ?? process.env["QQBOT_APP_SECRET"];
542
- if (appId === void 0 || appId.length === 0 || appSecret === void 0 || appSecret.length === 0) throw new Error(`qqbot: appId and appSecret are required; set them in Settings → QQ Bot, or export ${QQBOT_APP_ID_ENV}/${QQBOT_APP_SECRET_ENV}`);
543
- return {
544
- appId,
545
- appSecret
546
- };
547
- }
548
- /**
549
541
  * Mount the QQ bot bridge. It registers the `/qqbot` command and connects the
550
542
  * gateway only when a session runs `/qqbot on`; the bound agent's own scope,
551
543
  * history, and cwd are reused unchanged. While bound, it also answers that
552
544
  * agent's approval requests through the originating QQ chat.
553
545
  * @param ctx - Cordis context carrying the agent registry, commands, and session events.
554
- * @param config - composition entry; the user layer of the `qqbot` settings
555
- * namespace resolves over it once a settings provider is mounted.
546
+ * @param config - composition entry; the `qqbot` settings section resolves its
547
+ * user layer over it once a settings provider is mounted.
556
548
  */
557
549
  function apply(ctx, config) {
558
550
  const logger = ctx.logger;
@@ -561,14 +553,36 @@ function apply(ctx, config) {
561
553
  let boundSectionDispose;
562
554
  let gateway;
563
555
  let gatewaySettings;
556
+ let rejudgeGeneration = 0;
564
557
  const originByMessage = /* @__PURE__ */ new Map();
565
558
  const originByTurn = /* @__PURE__ */ new Map();
566
559
  const replyText = /* @__PURE__ */ new Map();
567
560
  let currentOrigin;
568
561
  let pendingApproval;
569
- /** Resolve the identity the bridge uses right now, or throw for the caller. */
570
- function resolveCurrentCredentials() {
571
- return resolveCredentials(readSection());
562
+ /**
563
+ * Resolve the identity the bridge uses right now, or throw for the caller.
564
+ * Layer ladder: the credential store (written by Settings → QQ Bot), then
565
+ * the settings section (settings.yaml over the cordis.yml base), then the
566
+ * same-named environment variables.
567
+ * @returns the concrete credentials.
568
+ */
569
+ async function resolveCurrentCredentials() {
570
+ const section = readSection();
571
+ const credentials = ctx.get("credentials");
572
+ let appId = section.appId;
573
+ let appSecret = section.appSecret;
574
+ if (credentials !== void 0) {
575
+ const [appIdCredential, appSecretCredential] = await Promise.all([credentials.resolve(QQBOT_APP_ID_REF), credentials.resolve(QQBOT_APP_SECRET_REF)]);
576
+ if (appIdCredential !== void 0) appId = appIdCredential.value;
577
+ if (appSecretCredential !== void 0) appSecret = appSecretCredential.value;
578
+ }
579
+ appId = appId ?? process.env["QQBOT_APP_ID"];
580
+ appSecret = appSecret ?? process.env["QQBOT_APP_SECRET"];
581
+ if (appId === void 0 || appId.length === 0 || appSecret === void 0 || appSecret.length === 0) throw new Error(`qqbot: appId and appSecret are required; set them in Settings → QQ Bot, or export ${QQBOT_APP_ID_ENV}/${QQBOT_APP_SECRET_ENV}`);
582
+ return {
583
+ appId,
584
+ appSecret
585
+ };
572
586
  }
573
587
  /** Resolve the reply path for one inbound message. */
574
588
  function replyPathOf(message) {
@@ -611,9 +625,10 @@ function apply(ctx, config) {
611
625
  agent.followup(userMessage);
612
626
  }
613
627
  /** Connect the gateway for the first bound session, or reuse the live one. */
614
- function ensureGateway() {
628
+ async function ensureGateway() {
615
629
  if (gateway === void 0) {
616
- const creds = resolveCurrentCredentials();
630
+ const creds = await resolveCurrentCredentials();
631
+ rejudgeGeneration += 1;
617
632
  gatewaySettings = {
618
633
  appId: creds.appId,
619
634
  appSecret: creds.appSecret,
@@ -634,48 +649,53 @@ function apply(ctx, config) {
634
649
  return gateway;
635
650
  }
636
651
  /** Bind a session: register its channel section, then connect the gateway. */
637
- function bind(agent) {
652
+ async function bind(agent) {
653
+ let live;
638
654
  try {
639
- resolveCurrentCredentials();
655
+ live = await ensureGateway();
640
656
  } catch (_missingCredentials) {
641
657
  return `QQ bot credentials are not configured. Set AppID/AppSecret in Settings → QQ Bot, or export ${QQBOT_APP_ID_ENV}/${QQBOT_APP_SECRET_ENV}.`;
642
658
  }
643
659
  boundAgent = agent;
644
660
  boundSectionDispose = agent.ctx.systemPrompt.section(channelSection);
645
- ensureGateway().start().catch((error) => {
661
+ live.start().catch((error) => {
646
662
  const detail = error instanceof Error ? error.message : String(error);
647
663
  logger.warn(`qqbot: gateway start failed: ${detail}`);
648
664
  });
649
665
  return null;
650
666
  }
651
667
  /**
652
- * Re-evaluate the settings section against the live gateway: removed
668
+ * Re-evaluate the credential layers against the live gateway: removed
653
669
  * credentials close it; a changed identity or sandbox flag restarts it with
654
- * the new values, keeping the binding.
670
+ * the new values, keeping the binding. Asynchronous by nature (credential
671
+ * resolution is a per-call read), so a generation counter and a
672
+ * gateway-identity check discard stale evaluations.
655
673
  */
656
- function rejudgeSettings() {
674
+ function rejudgeIdentity() {
657
675
  const active = gateway;
658
676
  if (active === void 0) return;
659
- let creds;
660
- try {
661
- creds = resolveCurrentCredentials();
662
- } catch (_missingCredentials) {
677
+ const generation = ++rejudgeGeneration;
678
+ resolveCurrentCredentials().then((creds) => {
679
+ if (generation !== rejudgeGeneration || gateway !== active) return;
680
+ const sandbox = readSection().sandbox === true;
681
+ const current = gatewaySettings;
682
+ if (current !== void 0 && current.appId === creds.appId && current.appSecret === creds.appSecret && current.sandbox === sandbox) return;
683
+ active.close();
684
+ gateway = void 0;
685
+ gatewaySettings = void 0;
686
+ if (boundAgent === void 0) return;
687
+ ensureGateway().then((live) => {
688
+ live.start();
689
+ }, (error) => {
690
+ const detail = error instanceof Error ? error.message : String(error);
691
+ logger.warn(`qqbot: gateway restart failed: ${detail}`);
692
+ });
693
+ }, () => {
694
+ if (generation !== rejudgeGeneration || gateway !== active) return;
663
695
  logger.warn("qqbot: credentials removed while connected; gateway closed");
664
696
  active.close();
665
697
  gateway = void 0;
666
698
  gatewaySettings = void 0;
667
- return;
668
- }
669
- const sandbox = readSection().sandbox === true;
670
- const current = gatewaySettings;
671
- if (current !== void 0 && current.appId === creds.appId && current.appSecret === creds.appSecret && current.sandbox === sandbox) return;
672
- active.close();
673
- gateway = void 0;
674
- gatewaySettings = void 0;
675
- if (boundAgent === void 0) return;
676
- ensureGateway().start().catch((error) => {
677
- const detail = error instanceof Error ? error.message : String(error);
678
- logger.warn(`qqbot: gateway restart failed: ${detail}`);
679
699
  });
680
700
  }
681
701
  /** Disconnect and drop the binding, unwinding the scoped section. */
@@ -769,10 +789,10 @@ function apply(ctx, config) {
769
789
  ctx.commands.register({
770
790
  name: "qqbot",
771
791
  description: "Connect or disconnect the QQ official bot for this session (/qqbot on | off)",
772
- handler: (invocation) => {
792
+ handler: async (invocation) => {
773
793
  const input = invocation.rawInput.trim();
774
794
  if (input === "on") {
775
- const failure = bind(invocation.agent);
795
+ const failure = await bind(invocation.agent);
776
796
  if (failure !== null) return {
777
797
  kind: "error",
778
798
  text: failure
@@ -795,12 +815,15 @@ function apply(ctx, config) {
795
815
  };
796
816
  }
797
817
  });
818
+ ctx.on("credentials/updated", (ref) => {
819
+ if (ref === QQBOT_APP_ID_REF || ref === QQBOT_APP_SECRET_REF) rejudgeIdentity();
820
+ });
798
821
  installSettingsSection(ctx, QQBOT_SETTINGS_NAMESPACE, Config, config, {
799
822
  setSource: (current) => {
800
823
  readSection = current;
801
824
  },
802
825
  onChange: () => {
803
- rejudgeSettings();
826
+ rejudgeIdentity();
804
827
  }
805
828
  });
806
829
  ctx.effect(() => () => {
@@ -808,4 +831,4 @@ function apply(ctx, config) {
808
831
  }, "qqbot");
809
832
  }
810
833
  //#endregion
811
- export { Config, QQBOT_APP_ID_ENV, QQBOT_APP_SECRET_ENV, QQBOT_CHANNEL_ORDER, QQBOT_CHANNEL_PROMPT, QQBOT_CHANNEL_SECTION, QQBOT_SETTINGS_NAMESPACE, apply, inject, name, parseAnswer };
834
+ export { Config, QQBOT_APP_ID_ENV, QQBOT_APP_ID_REF, QQBOT_APP_SECRET_ENV, QQBOT_APP_SECRET_REF, QQBOT_CHANNEL_ORDER, QQBOT_CHANNEL_PROMPT, QQBOT_CHANNEL_SECTION, QQBOT_SETTINGS_NAMESPACE, apply, inject, name, parseAnswer };
@@ -3,10 +3,11 @@ import type { QqbotSettingsInjected } from './index.ts';
3
3
  /** Full settings.section component props: runtime share + injected share + the locale seat. */
4
4
  export type QqbotSettingsSectionProps = PropsRuntime<'settings.section'> & InjectFace<QqbotSettingsInjected> & PropsLocale<'qqbot'>;
5
5
  /**
6
- * The QQ Bot settings page: AppID, AppSecret, and sandbox fields over the
7
- * Host's redacted namespace view. The AppSecret is write-only on the wire, so
8
- * the field renders blank and "keep unchanged" is the empty-value meaning;
9
- * the clear button removes a stored secret. Saving applies every changed
10
- * field in one ordered write, and the Host restarts a live gateway in place.
6
+ * The QQ Bot settings page: AppID and AppSecret over the credential seam's
7
+ * value-free views. Both values are write-only on the wire — the page shows
8
+ * configured/not-configured chips and the inputs mean "enter a new value";
9
+ * an empty field keeps the stored one, and the clear buttons remove it.
10
+ * Saving applies every changed field in one ordered write, and the Host
11
+ * restarts a live gateway in place.
11
12
  */
12
13
  export declare function QqbotSettingsSection({ useQqbotSettings, useQqbotBound, save, t }: QqbotSettingsSectionProps): import("react").JSX.Element;
@@ -10,17 +10,19 @@ export declare const zh: {
10
10
  'settings.nav': string;
11
11
  'settings.title': string;
12
12
  'settings.desc': string;
13
+ 'chip.set': string;
14
+ 'chip.unset': string;
13
15
  'appId.label': string;
14
16
  'appId.placeholder': string;
17
+ 'appId.placeholder.set': string;
15
18
  'appId.hint': string;
19
+ 'clear.appId': string;
16
20
  'appSecret.label': string;
17
21
  'appSecret.placeholder': string;
18
22
  'appSecret.placeholder.set': string;
19
23
  'appSecret.hint': string;
20
24
  'appSecret.hint.set': string;
21
- 'clear.label': string;
22
- 'sandbox.label': string;
23
- 'sandbox.hint': string;
25
+ 'clear.secret': string;
24
26
  'save.label': string;
25
27
  'save.busy': string;
26
28
  'save.ok': string;
@@ -42,17 +44,19 @@ export declare const en: {
42
44
  'settings.nav': string;
43
45
  'settings.title': string;
44
46
  'settings.desc': string;
47
+ 'chip.set': string;
48
+ 'chip.unset': string;
45
49
  'appId.label': string;
46
50
  'appId.placeholder': string;
51
+ 'appId.placeholder.set': string;
47
52
  'appId.hint': string;
53
+ 'clear.appId': string;
48
54
  'appSecret.label': string;
49
55
  'appSecret.placeholder': string;
50
56
  'appSecret.placeholder.set': string;
51
57
  'appSecret.hint': string;
52
58
  'appSecret.hint.set': string;
53
- 'clear.label': string;
54
- 'sandbox.label': string;
55
- 'sandbox.hint': string;
59
+ 'clear.secret': string;
56
60
  'save.label': string;
57
61
  'save.busy': string;
58
62
  'save.ok': string;
@@ -1,46 +1,54 @@
1
1
  /**
2
- * Settings-page transport for the qqbot namespace: reads the Host's redacted
3
- * settings view and writes user edits as path ops, mirroring the result in a
4
- * snapshot store the section renders through. The Host is the single fact
5
- * source every mutation writes through the wire and the page re-renders
6
- * from the response view (or a reload after a refusal).
2
+ * Settings-page transport for the QQ bot identity: reads the credential seam's
3
+ * value-free views (`configured`/`source`/`writable`, never values) and writes
4
+ * user edits through `credentials.set`/`credentials.unset`, mirroring the
5
+ * result in a snapshot store the section renders through. The credentials
6
+ * domain is used because the settings wire face serves only a hardcoded
7
+ * namespace allowlist the harness owns; credentials take arbitrary reference
8
+ * names, so an out-of-tree plugin needs no harness patch.
7
9
  */
8
10
  import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client';
9
11
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
10
- /** The settings namespace the Host plugin registers its identity under. */
11
- export declare const QQBOT_SETTINGS_NAMESPACE = "qqbot";
12
+ /**
13
+ * Credential reference holding the robot application id. Deliberately NOT the
14
+ * environment-variable name: the credential store's env layer shadows a
15
+ * same-named ref and makes it read-only, which would disable the settings
16
+ * page wherever `QQBOT_APP_ID` is exported.
17
+ */
18
+ export declare const QQBOT_APP_ID_REF = "QQBOT_CRED_APP_ID";
19
+ /**
20
+ * Credential reference holding the robot application secret; distinct from
21
+ * `QQBOT_APP_SECRET` for the same shadowing reason.
22
+ */
23
+ export declare const QQBOT_APP_SECRET_REF = "QQBOT_CRED_APP_SECRET";
12
24
  /** One user edit the section sends: absent fields stay untouched. */
13
25
  export interface QqbotSettingsPatch {
14
- /** New AppID; an empty string clears the field (re-inherits the base layer). */
26
+ /** New AppID; omitted or empty keeps the stored value. */
15
27
  appId?: string;
16
28
  /** New AppSecret; omitted or empty keeps the stored value. */
17
29
  appSecret?: string;
18
- /** New sandbox flag. */
19
- sandbox?: boolean;
30
+ /** Remove the stored AppID. */
31
+ clearAppId?: boolean;
20
32
  /** Remove the stored AppSecret. */
21
33
  clearSecret?: boolean;
22
34
  }
23
- /** Snapshot state mirrored from the Host's redacted namespace view. */
35
+ /** Snapshot state mirrored from the credential seam's views. */
24
36
  export interface QqbotSettingsState {
25
- /** `loading` until the first accepted view; `error` after a failed load. */
37
+ /** `loading` until the first accepted views; `error` after a failed load. */
26
38
  status: 'idle' | 'loading' | 'ready' | 'error';
27
- /** Resolved AppID (base layer overridden by the user section); '' when unset. */
28
- appId: string;
29
- /** Resolved sandbox flag. */
30
- sandbox: boolean;
31
- /** Whether the user section stores an AppSecret (the value never rides the wire). */
39
+ /** Whether an AppID is currently stored (the value never rides the wire). */
40
+ appIdSet: boolean;
41
+ /** Whether an AppSecret is currently stored (the value never rides the wire). */
32
42
  secretSet: boolean;
33
- /** Whether the Host settings document accepts writes. */
43
+ /** Whether the credential store accepts writes for both references. */
34
44
  writable: boolean;
35
- /** Namespace revision the next write fences against. */
36
- revision: number | undefined;
37
45
  /** Human failure text of the latest load or save; null while healthy. */
38
46
  error: string | null;
39
47
  }
40
48
  /**
41
- * Serializes the qqbot namespace's Host reads and writes behind one snapshot
42
- * store. Latest load wins; a refused save reloads the namespace so the form
43
- * re-renders from the fresh view and the user can re-apply the edit.
49
+ * Serializes the two credential references' Host reads and writes behind one
50
+ * snapshot store. Latest load wins; a refused save reloads the views so the
51
+ * form re-renders from the fresh state and the user can re-apply the edit.
44
52
  */
45
53
  export declare class QqbotSettingsController {
46
54
  private readonly api;
@@ -48,24 +56,22 @@ export declare class QqbotSettingsController {
48
56
  readonly store: SnapshotStore<QqbotSettingsState>;
49
57
  private generation;
50
58
  /**
51
- * @param api - the settings wire face.
59
+ * @param api - the credentials wire face.
52
60
  */
53
- constructor(api: Pick<IApiClient, 'settings'>);
61
+ constructor(api: Pick<IApiClient, 'credentials'>);
54
62
  /**
55
- * Refresh the snapshot from `settings.describe`; a failure keeps the last
56
- * good state and surfaces the error.
63
+ * Refresh the snapshot from `credentials.describe`; a failure keeps the
64
+ * last good state and surfaces the error.
57
65
  * @returns settlement after the read.
58
66
  */
59
67
  load(): Promise<void>;
60
68
  /**
61
- * Apply one user edit as ordered path ops; a refusal reloads the namespace
62
- * and returns its message.
69
+ * Apply one user edit as ordered credential writes, stopping at the first
70
+ * refusal; a refusal reloads the views and returns its message.
63
71
  * @param patch - the fields the user changed.
64
72
  * @returns null on success; the Host's failure text otherwise.
65
73
  */
66
74
  save(patch: QqbotSettingsPatch): Promise<string | null>;
67
- /** Fold one redacted wire view into the snapshot. */
68
- private accept;
69
75
  }
70
76
  /**
71
77
  * Refresh only after the section left idle, so an unopened page never fetches
@@ -7,10 +7,12 @@
7
7
  * QQ gateway; `/qqbot off` disconnects. Nothing connects until a session opts
8
8
  * in, and QQ messages then arrive as ordinary user messages on that agent.
9
9
  *
10
- * The robot identity (AppID/AppSecret) lives in the `qqbot` settings section —
11
- * the Settings QQ Bot page of the web GUI — with the composition entry as
12
- * the base layer and `QQBOT_APP_ID`/`QQBOT_APP_SECRET` as an environment
13
- * fallback. Committed settings changes restart a live gateway in place.
10
+ * The robot identity (AppID/AppSecret) resolves in order: the credential
11
+ * references `QQBOT_APP_ID`/`QQBOT_APP_SECRET` (written by the Settings QQ
12
+ * Bot page into the harness credential store), then the `qqbot` settings
13
+ * section (whose base layer is the composition entry), then the same-named
14
+ * environment variables. Committed credential or settings changes restart a
15
+ * live gateway in place.
14
16
  *
15
17
  * The bridge is also an approval answerer for its bound agent: a tool call that
16
18
  * asks for confirmation is forwarded to the originating QQ chat as a prompt,
@@ -20,6 +22,7 @@
20
22
  */
21
23
  import type { Context } from '@deepseek-ai/cordis';
22
24
  import Schema from '@deepseek-ai/schemastery';
25
+ import { type CredentialRef } from '@deepseek-ai/dsh-credentials';
23
26
  import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval';
24
27
  import type { QqbotConfig } from './types.ts';
25
28
  export type { QqbotConfig } from './types.ts';
@@ -32,7 +35,11 @@ export declare const Config: Schema<QqbotConfig>;
32
35
  export declare const QQBOT_APP_ID_ENV = "QQBOT_APP_ID";
33
36
  /** Environment variable fallback for the robot application secret. */
34
37
  export declare const QQBOT_APP_SECRET_ENV = "QQBOT_APP_SECRET";
35
- /** Settings namespace carrying the robot identity (Settings → QQ Bot). */
38
+ /** Credential reference for the robot application id (written by Settings → QQ Bot). */
39
+ export declare const QQBOT_APP_ID_REF: CredentialRef;
40
+ /** Credential reference for the robot application secret (written by Settings → QQ Bot). */
41
+ export declare const QQBOT_APP_SECRET_REF: CredentialRef;
42
+ /** Settings namespace carrying the identity base layer (the cordis.yml config). */
36
43
  export declare const QQBOT_SETTINGS_NAMESPACE: import("@deepseek-ai/dsh-settings").SettingsNamespace;
37
44
  /** Prompt section name registered on the bound agent's scope. */
38
45
  export declare const QQBOT_CHANNEL_SECTION = "qqbot:channel";
@@ -58,7 +65,7 @@ export declare function parseAnswer(text: string): ApprovalOutcome | undefined;
58
65
  * history, and cwd are reused unchanged. While bound, it also answers that
59
66
  * agent's approval requests through the originating QQ chat.
60
67
  * @param ctx - Cordis context carrying the agent registry, commands, and session events.
61
- * @param config - composition entry; the user layer of the `qqbot` settings
62
- * namespace resolves over it once a settings provider is mounted.
68
+ * @param config - composition entry; the `qqbot` settings section resolves its
69
+ * user layer over it once a settings provider is mounted.
63
70
  */
64
71
  export declare function apply(ctx: Context, config: QqbotConfig): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-qqbot",
3
3
  "description": "QQ official bot bridge for DeepSeek Harness: /qqbot command, bound-session routing, QQ-side approvals, the composer connection toggle, and the QQ Bot settings page",
4
- "version": "1.0.0",
4
+ "version": "1.0.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -46,6 +46,7 @@
46
46
  "test": "vitest run"
47
47
  },
48
48
  "dependencies": {
49
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.5",
49
50
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.5",
50
51
  "@deepseek-ai/schemastery": "^3.18.1"
51
52
  },
@@ -79,6 +80,7 @@
79
80
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.5",
80
81
  "@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.5",
81
82
  "@deepseek-ai/dsh-commands": "^0.1.0-rc.5",
83
+ "@deepseek-ai/dsh-credentials": "^0.1.0-rc.5",
82
84
  "@deepseek-ai/dsh-invariants": "^0.1.0-rc.5",
83
85
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.5",
84
86
  "@deepseek-ai/dsh-session": "^0.1.0-rc.5",