mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
feat: add message source context enhancement
This commit is contained in:
parent
5928b33057
commit
0cb79f74e9
69 changed files with 6217 additions and 597 deletions
13
README.en.md
13
README.en.md
|
|
@ -128,6 +128,19 @@ Use the proxy URL required by your network and restart the Host after changing i
|
|||
| --- | --- |
|
||||
| Bot workspace | Each bot stores its workspace independently. New bots start with the Host's current working directory, which can later be changed from the bot card. |
|
||||
| Agent Preset | Each bot can choose an Agent Preset on its settings card. When none is chosen, new Sessions follow the Host's `agent-presets.default`. A channel-level `config.agentPreset` is only the default for later new bots on that channel. Changing the preset never modifies or clears existing Sessions; if the current chat already has a Session, send `/new` and then a regular message to create one with the new selection. |
|
||||
| Context enhancement | Open settings from a bot card to enable groups and DMs independently. Both switches default to off, including for existing bots after an upgrade. |
|
||||
|
||||
### Context enhancement
|
||||
|
||||
Open **Context enhancement** on a bot card to choose conversation scopes, source fields and guidance, then **Save**. The five available fields are `channel`, `conversationType`, `senderId`, `senderName` and `botId`; only `senderId` is selected by default. Only selected values already available in the incoming message are included; no platform profile API is queried. Weixin currently supports DMs only.
|
||||
|
||||
When enabled, ordinary user messages receive a `<dsh_im_source>` prefix. Nonempty guidance is automatically wrapped in `<dsh_im_source_guidance>` tags. Guidance starts empty; the question-mark help includes instructions and an example, while **Use example** and **Clear** only edit the draft. No selected fields means no source block. Commands, approvals and question answers keep their existing control paths.
|
||||
|
||||
When the current conversation scope is off, text, images, files and Session behavior are unchanged, without enhancement assembly or extra network queries. Unsaved or cancelled drafts have no effect. Saving does not reconnect bots or recreate Sessions; messages already received retain their original configuration snapshot.
|
||||
|
||||
These blocks are **user-message content**, not changes to Harness, system prompts or permissions. Identifiers may contain platform user IDs or phone-number-like values and are sent to the current model and stored in Session history. Turning the feature off stops future additions; it does not erase existing history.
|
||||
|
||||
### Access modes
|
||||
|
||||
Each Telegram bot has its own access-mode control on its bot card. Existing and newly connected bots both default to **Compatible mode**: DMs receive replies, while group messages require a mention of or reply to the bot. Restrictions apply only after explicitly switching that bot to **Safe mode (private-chat allowlist)**. Safe mode ignores every group message and admits only numeric User IDs in that bot's allowlist. Enter one ID per line. Switching back to Compatible mode retains the allowlist without enforcing it, so it is available when Safe mode is enabled again. An empty allowlist in Safe mode rejects all inbound messages for that bot.
|
||||
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -131,6 +131,19 @@ dsh web
|
|||
| --- | --- |
|
||||
| 机器人工作区 | 每个机器人独立保存工作区。新机器人默认使用 Host 当时的工作目录;之后可在机器人卡片中修改。 |
|
||||
| Agent Preset | 每个机器人可在设置页卡片中选择 Agent Preset。未选择时跟随 Host 的 `agent-presets.default`;渠道级 `config.agentPreset` 只作为该渠道之后新接入机器人的默认值。切换不会修改或清空已有会话;若当前聊天已有会话,需先发送 `/new`,再发送一条普通消息,才会按新选择创建会话。 |
|
||||
| 上下文增强 | 从机器人卡片打开设置,分别决定群聊、私聊是否增强;两个开关默认均关闭,旧机器人升级后也不会自动开启。 |
|
||||
|
||||
### 上下文增强
|
||||
|
||||
点击机器人卡片中的「上下文增强」,设置群聊/私聊范围、来源字段和增强提示词,点击「保存」后生效。五个可选字段为 `channel`、`conversationType`、`senderId`、`senderName`、`botId`,默认只选择 `senderId`;只发送勾选且当前消息已有的值,不查询平台 API 补全。微信当前只支持私聊。
|
||||
|
||||
开启后,插件在普通用户消息前附加 `<dsh_im_source>` 来源块;非空的增强提示词自动包裹为 `<dsh_im_source_guidance>`。增强提示词默认留空,标题旁的问号提供使用说明和示例,也可以「填入示例」或「清空」正文;字段全部取消时不生成来源块。命令、审批和问题回答继续走原有控制链路。
|
||||
|
||||
当前会话类型未开启时,原有文字、图片、文件和会话处理保持不变,不组装增强内容,也不新增网络查询。草稿、清空后取消等操作不改变运行配置;保存不重连机器人、不重建会话,已经接收的消息仍使用接收时的配置。
|
||||
|
||||
来源与提示词都属于**用户消息内容**,不修改 Harness、系统提示词或权限。来源标识可能包含平台用户 ID 或电话号码形式的信息,并随消息交给当前模型、留在会话历史中;关闭只停止后续附加,不删除已经写入的历史。
|
||||
|
||||
### 访问模式
|
||||
|
||||
每个 Telegram 机器人都可以在自己的卡片中切换访问模式。旧机器人和新接入机器人均默认使用**兼容模式**:私聊直接响应,群聊仅在提及机器人或回复机器人消息时响应。只有主动切换到**安全模式(私聊白名单)**后,机器人才会忽略全部群聊,并只接受该机器人白名单中的数字 User ID。白名单每行一个 ID、按机器人独立保存;切回兼容模式时会保留但不使用,再切回安全模式即可继续使用。安全模式的空白名单会拒绝该机器人的所有入站消息。
|
||||
|
||||
|
|
|
|||
875
docs/方案/dsh-im-产品价值与演进分析.md
Normal file
875
docs/方案/dsh-im-产品价值与演进分析.md
Normal file
|
|
@ -0,0 +1,875 @@
|
|||
# dsh-im 产品价值与演进分析
|
||||
|
||||
> 状态:产品价值分析,不代表已经决定实施具体功能
|
||||
>
|
||||
> 初始日期:2026-08-26
|
||||
>
|
||||
> 更新日期:2026-08-29
|
||||
>
|
||||
> 讨论来源:[Issue #58](https://github.com/xmanrui/dsh-im/issues/58)、[Issue #63](https://github.com/xmanrui/dsh-im/issues/63)、[Issue #65](https://github.com/xmanrui/dsh-im/issues/65)
|
||||
>
|
||||
> 关联文档:[机器人消息来源元数据注入方案](./机器人消息来源元数据注入方案.md)、[风险说明](https://github.com/xmanrui/dsh-im/issues/58#issuecomment-5414350619)
|
||||
>
|
||||
> 说明:本文最初从 Issue #58 出发,现结合 Issue #63 与 Issue #65,对 dsh-im 的整体产品价值与演进方向进行梳理。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
Issue #58 与 Issue #65 分别提出了两个表面上很小、实际上会改变 dsh-im 产品定位的问题:
|
||||
|
||||
```text
|
||||
Issue #58:用户发来消息时,Agent 能否理解谁在什么场景说话?
|
||||
Issue #65:用户没有先发消息时,宿主任务能否在需要时找到用户?
|
||||
```
|
||||
|
||||
前者补充了**理解用户与场景**的能力,后者补充了**主动触达与结果交付**的能力。它们组合后暴露出一个比“方便接入 Harness”更大的产品价值:
|
||||
|
||||
> **dsh-im 可以成为 DeepSeek Harness 面向真实用户世界的交互层。**
|
||||
|
||||
dsh-im 的价值正在从单一的渠道连接能力,扩展为四类相互加强的长期资产:
|
||||
|
||||
| 资产 | 核心问题 | 产品价值 |
|
||||
| --- | --- | --- |
|
||||
| 入口 | 用户通过哪个渠道和机器人来到 Agent? | 把机器人变成部门、品牌、业务线或服务的 AI 入口 |
|
||||
| 场景 | 谁在说话,当前是私聊、群聊还是其他交互环境? | 让 Agent 理解用户所处的真实沟通场景 |
|
||||
| 触达 | 任务完成或业务事件发生后,如何回到正确的用户? | 让 Agent 从被动应答走向异步工作和主动服务 |
|
||||
| 闭环 | 结果送达后,用户如何确认、追问、审批并继续流程? | 将一次回答变成持续的 Agent—用户交互关系 |
|
||||
|
||||
Issue #58 表面上是把以下五个字段提供给处理消息的 AI:
|
||||
|
||||
```text
|
||||
channel
|
||||
conversationType
|
||||
senderId
|
||||
senderName
|
||||
botId
|
||||
```
|
||||
|
||||
如果只把它理解为“在用户提示词前增加一段 JSON”,功能本身并不大;它真正有价值的方向是:
|
||||
|
||||
> **为 dsh-im 建立一层统一的、按机器人配置的消息上下文。**
|
||||
|
||||
再结合 Issue #65 所启发的主动触达价值,dsh-im 的产品定位可以概括为:
|
||||
|
||||
```text
|
||||
原定位:让机器人更方便地接入 DeepSeek Harness
|
||||
↓
|
||||
当前更准确的定位:面向 DeepSeek Harness 的多渠道 Agent 交互网关
|
||||
↓
|
||||
长期方向:连接用户沟通场景与 Agent 能力的交互基础设施
|
||||
```
|
||||
|
||||
这不是否定“接入插件”的价值,而是从它向上扩展一层:多渠道连接仍是基础和产品入口,但 dsh-im 长期不只解决“消息如何送到 Harness”,还可以解决“用户如何在真实沟通场景中使用 Agent”和“Agent 如何在需要时重新找到用户”。
|
||||
|
||||
这层上下文位于平台原始事件和 Harness 用户消息之间,把模型原本看不到的渠道、会话和参与者信息转换成九渠道统一语义:
|
||||
|
||||
```text
|
||||
平台原始事件
|
||||
→ dsh-im 归一化消息来源
|
||||
→ 当前机器人的上下文使用策略
|
||||
→ 当前用户消息
|
||||
→ Harness Session
|
||||
→ 处理该消息的 Agent/模型
|
||||
```
|
||||
|
||||
这层上下文让当前 Session 背后的 AI 获得此前缺失的**场景感知**:谁在说话、通过哪个入口说、当前是私聊还是群聊。
|
||||
|
||||
两个 Issue 还共同揭示了 dsh-im 的价值会产生复利:
|
||||
|
||||
```text
|
||||
每增加一个渠道,所有 Agent 交互能力都多一个用户入口;
|
||||
每增加一种交互能力,所有已接入渠道都可能获得新价值;
|
||||
每增加一个业务机器人,就多一个部门、品牌或业务线入口;
|
||||
每接入一种宿主任务或业务事件,就多一种主动服务用户的方式。
|
||||
```
|
||||
|
||||
因此,dsh-im 的含金量不再只来自“已经接入九个渠道”,而来自它正在积累一套跨渠道的用户入口、场景理解、主动触达、结果交付和交互闭环。无论具体 Issue 最终是否实施,这些讨论都帮助项目更清楚地看到了自身作为 **Agent 与用户之间“最后一公里”交互层**的价值。
|
||||
|
||||
## 2. 本文所称的 Agent
|
||||
|
||||
本文的 **Agent** 不是飞书、微信、Slack 等渠道机器人,也不是 dsh-im 或 Harness Session 本身,而是:
|
||||
|
||||
> **当前 Harness Session 背后,负责消费用户消息、调用模型和工具并生成结果的 AI 执行体。**
|
||||
|
||||
概念关系为:
|
||||
|
||||
```text
|
||||
IM 用户
|
||||
→ 渠道机器人:平台消息入口
|
||||
→ dsh-im:渠道适配与消息装配
|
||||
→ Harness Session:会话历史与执行状态
|
||||
→ Agent:处理消息的 AI 执行体
|
||||
→ 模型:Agent 用于理解和生成内容的组成部分
|
||||
```
|
||||
|
||||
Agent Preset 决定一个新 Session 的 Agent 如何被组装,包括 persona、系统提示词段、工具和 Skills;消息来源上下文描述的是当前这一轮消息的外部场景,两者不是同一个概念。
|
||||
|
||||
## 3. 当前最直接的用户价值
|
||||
|
||||
### 3.1 群聊中区分当前发言人
|
||||
|
||||
这是五个字段在当前产品中最明确的价值。
|
||||
|
||||
群聊中的多位成员共享一个 Session 时,Agent 可以看到连续的消息,却未必知道每条消息由谁发送。例如:
|
||||
|
||||
```text
|
||||
张三:我下周一休假。
|
||||
李四:我周二也休假。
|
||||
```
|
||||
|
||||
当每条消息携带独立的 `senderId` 和可用的 `senderName` 后,Agent 有机会把两项陈述分别归属到张三和李四,而不是把两次“我”理解为同一个人。
|
||||
|
||||
由此可以改善:
|
||||
|
||||
- 群聊问答中的称呼和回应对象;
|
||||
- 会议或讨论总结中的观点归属;
|
||||
- 行动项、承诺和待办的成员归属;
|
||||
- 同名成员之外的稳定参与者区分;
|
||||
- 后续追问时对前序发言人的引用。
|
||||
|
||||
这主要改善的是**共享群聊会话内的说话人识别**。
|
||||
|
||||
### 3.2 区分私聊和群聊语境
|
||||
|
||||
同一段文字在私聊和群聊中可能具有不同含义:
|
||||
|
||||
- 私聊中的“帮我总结一下”通常面向当前用户。
|
||||
- 群聊中的“帮我总结一下”通常面向整个讨论。
|
||||
- 私聊适合针对个人连续追问。
|
||||
- 群聊需要避免把某位成员的意见表述成全体共识。
|
||||
- 群聊回复可能需要明确称呼当前发言人。
|
||||
|
||||
`conversationType` 为 Agent 提供了理解这种语境差异的基础。
|
||||
|
||||
### 3.3 感知渠道使用场景
|
||||
|
||||
`channel` 可以帮助 Agent理解用户当前所处的交流环境,例如移动端即时通信、团队协作工具或社区频道。
|
||||
|
||||
潜在用途包括:
|
||||
|
||||
- 在移动端渠道中倾向于简洁回答;
|
||||
- 在协作渠道中倾向于任务列表和结构化总结;
|
||||
- 在群组或社区渠道中采用适合公开讨论的表达;
|
||||
- 在生成操作说明时考虑用户当前是否方便阅读长内容。
|
||||
|
||||
渠道格式渲染仍然应该由 dsh-im 负责;`channel` 的价值是场景提示,不是让模型直接处理平台 API 或消息协议。
|
||||
|
||||
### 3.4 识别机器人入口
|
||||
|
||||
`botId` 可以标记消息通过哪个机器人进入。当前每个机器人已经独立保存 Workspace、Agent Preset 和 Session 映射,因此它对“防止上下文混淆”的直接帮助较弱。
|
||||
|
||||
它的中长期价值在于表示业务入口:
|
||||
|
||||
```text
|
||||
售前机器人
|
||||
售后机器人
|
||||
HR 机器人
|
||||
项目管理机器人
|
||||
社区机器人
|
||||
不同品牌或租户的机器人
|
||||
```
|
||||
|
||||
但是原始 `botId` 只是一段内部标识。没有额外的业务映射时,模型无法从 `feishu_xxx` 推导它属于 HR、销售还是售后。若未来需要业务入口感知,还需要显式的 `botRole`、`businessLine` 或服务器端映射。
|
||||
|
||||
## 4. 五个字段的价值分布
|
||||
|
||||
| 字段 | 当前直接价值 | 中长期价值 | 局限 |
|
||||
| --- | --- | --- | --- |
|
||||
| `channel` | 提供渠道场景 | 渠道运营、内容策略和来源分析 | 不应让模型处理平台协议 |
|
||||
| `conversationType` | 区分私聊和群聊 | 交互策略和工作流入口 | 只提供事实,不自动产生行为 |
|
||||
| `senderId` | 稳定区分群聊参与者 | 统一用户身份、长期记忆、CRM/员工映射 | 不能直接作为可信身份或权限凭据 |
|
||||
| `senderName` | 自然称呼和发言归属 | 人物视图、会议总结展示 | 可变、可缺失,不适合做稳定键 |
|
||||
| `botId` | 标记机器人入口 | 部门、品牌、租户和业务线入口 | 没有业务映射时语义较弱 |
|
||||
|
||||
当前版本中,`conversationType`、`senderId` 和 `senderName` 对群聊体验的价值最直接;`channel` 提供通用场景;`botId` 更偏向未来的业务入口模型。
|
||||
|
||||
## 5. 从“来源数据”到“上下文使用策略”
|
||||
|
||||
只把原始 JSON 写进用户消息,只能保证模型看见数据,不能说明模型应该如何使用。因此讨论进一步发现了第二层能力:
|
||||
|
||||
```text
|
||||
来源数据
|
||||
→ 告诉模型当前发生了什么
|
||||
|
||||
上下文使用策略
|
||||
→ 告诉模型希望怎样理解和使用这些事实
|
||||
```
|
||||
|
||||
当前功能方案把“上下文增强”设计为机器人卡片上的设置入口。点击后打开弹窗,从上到下设置三个维度:
|
||||
|
||||
- **在哪些会话中使用**:群聊和私聊两个独立开关,默认均关闭。
|
||||
- **提供哪些来源事实**:五个字段中默认只选 `senderId`,也可按当前入口增加或取消字段。
|
||||
- **如何使用这些事实**:增强提示词默认留空,可从问号查看使用说明和示例,也支持一键填入示例或清空。
|
||||
|
||||
群聊与私聊共用字段选择和提示词正文,分别决定是否启用。例如提示词可以是:
|
||||
|
||||
```text
|
||||
有 senderId 时用它区分发言人;
|
||||
有 senderName 时可用于称呼和观点归属;
|
||||
不要在普通回复中展示 senderId 和 botId。
|
||||
```
|
||||
|
||||
用户只编辑增强提示词正文;非空时,`<dsh_im_source_guidance>` 和 `</dsh_im_source_guidance>` 由 dsh-im 自动添加。清空并保存后不再附加提示词块,也不会自动填入示例;所选来源字段仍按会话开关发送。
|
||||
|
||||
以当前会话已开启、五个字段全选且均可用、正文非空为例,dsh-im 最终发送:
|
||||
|
||||
```text
|
||||
<dsh_im_source>
|
||||
{"channel":"telegram","conversationType":"group","senderId":"123456","senderName":"张三","botId":"telegram_xxx"}
|
||||
</dsh_im_source>
|
||||
|
||||
<dsh_im_source_guidance>
|
||||
有 senderId 时用它区分发言人;
|
||||
有 senderName 时可用于称呼和观点归属;
|
||||
不要在普通回复中展示 senderId 和 botId。
|
||||
</dsh_im_source_guidance>
|
||||
|
||||
用户原始消息
|
||||
```
|
||||
|
||||
这使同一个 Agent Preset 可以被多个机器人复用,而各机器人通过自己的上下文策略表达不同入口的交流方式。
|
||||
|
||||
这项扩展已经超出原 Issue“传递五个字段”的最小范围。若实施,应该明确区分:
|
||||
|
||||
- 来源字段语义和真实值由 dsh-im 维护;用户可以选择发送哪些字段,不能手工改写来源事实;
|
||||
- 增强提示词按机器人保存正文,非空时由插件添加外围标签;首次未配置时保持为空,可选择填入示例;
|
||||
- 群聊、私聊独立控制启用范围;当前会话类型关闭时,两个增强块都不附加;
|
||||
- 所选字段无可用值时省略来源块,提示词为空时省略提示词块;两者都为空则保持原消息;
|
||||
- 使用说明属于用户提示词增强,不是系统提示词,也不是完整 Agent Preset;
|
||||
- 本方案共用一份提示词;若未来需要分场景的不同模板,可以另行扩展,不等同于当前的两个启用开关。
|
||||
|
||||
这让“上下文增强”从一次性的数据附加,进一步成为用户可以明确配置的上下文策略:哪些场景需要增强、提供哪些信息,以及希望模型怎样使用,三者各有独立的选择空间。
|
||||
|
||||
## 6. 它填补了现有产品中的一层空白
|
||||
|
||||
当前产品主要有两层配置:
|
||||
|
||||
```text
|
||||
Agent Preset
|
||||
→ 新 Session 的长期角色、提示词、工具和 Skills
|
||||
|
||||
用户消息
|
||||
→ 每一轮的临时任务输入
|
||||
```
|
||||
|
||||
消息来源能力可以补充中间两层:
|
||||
|
||||
```text
|
||||
Agent Preset
|
||||
→ Agent 的基础能力和长期角色
|
||||
|
||||
机器人上下文策略
|
||||
→ 这个入口希望如何理解渠道、会话和参与者
|
||||
|
||||
消息来源上下文
|
||||
→ 当前这一轮是谁、在哪里发言
|
||||
|
||||
用户正文
|
||||
→ 用户实际提出的问题
|
||||
```
|
||||
|
||||
这一中间层的特点是:
|
||||
|
||||
- 按机器人独立配置;
|
||||
- 不要求创建专用 Agent Preset;
|
||||
- 不修改 Harness 协议;
|
||||
- 可以在下一条消息立即生效;
|
||||
- 九个渠道使用相同字段语义;
|
||||
- 能与现有和未来的任意 Preset 组合。
|
||||
|
||||
从产品定位看,这比“增加五个字段”更接近一层轻量的**机器人上下文策略**。
|
||||
|
||||
## 7. dsh-im 的产品定位升级
|
||||
|
||||
### 7.1 从渠道连接器到 Agent 交互网关
|
||||
|
||||
dsh-im 原来解决的核心问题是:
|
||||
|
||||
> 如何让飞书、企业微信、钉钉、Slack 等渠道机器人更方便地接入 DeepSeek Harness?
|
||||
|
||||
在这一定位下,dsh-im 的主要价值是:
|
||||
|
||||
- 适配不同平台的事件和回复协议;
|
||||
- 把聊天映射到 Harness Session;
|
||||
- 将消息送入 Harness;
|
||||
- 把 Harness 运行结果返回原渠道。
|
||||
|
||||
这个定位仍然成立,但它只描述了技术连接关系。Issue #58 提出了一个更深的问题:
|
||||
|
||||
> 当消息已经能够送达 Harness 之后,Agent 是否知道这条消息是谁、在什么场景、通过哪个业务入口发出的?
|
||||
|
||||
一旦 dsh-im 开始回答这个问题,它就不再只是搬运消息的通道,而是开始承担以下职责:
|
||||
|
||||
```text
|
||||
平台事件
|
||||
→ 提取真实交互场景
|
||||
→ 归一化为 Agent 可消费的上下文
|
||||
→ 按机器人的交互策略组装消息
|
||||
→ 将 Agent 的结果以渠道原生方式交付给用户
|
||||
```
|
||||
|
||||
因此,更准确的当前定位可以表达为:
|
||||
|
||||
> **dsh-im 是面向 DeepSeek Harness 的多渠道 Agent 交互网关。**
|
||||
|
||||
长期愿景则可以表达为:
|
||||
|
||||
> **dsh-im 是连接用户沟通场景与 Agent 能力的交互基础设施。**
|
||||
|
||||
“插件”是 dsh-im 与 Harness 集成的技术形态,不应被用来限制其产品价值。它不需要替代 Harness,也能成为 Harness 与真实用户之间的关键交互层。
|
||||
|
||||
### 7.2 天然接近用户带来的四类稀缺资产
|
||||
|
||||
dsh-im 位于用户和 Agent 之间,比单纯的模型或工具编排层更接近真实使用现场。这使项目天然积累四类资产。
|
||||
|
||||
#### 入口资产
|
||||
|
||||
dsh-im 知道用户通过哪个渠道、哪个机器人来到 Agent:
|
||||
|
||||
```text
|
||||
渠道
|
||||
→ 机器人
|
||||
→ 部门、品牌、业务线或服务阶段
|
||||
→ 对应的 Agent 能力
|
||||
```
|
||||
|
||||
用户往往不会先进入一个通用 AI 界面再选择 Agent,而是直接在已有沟通工具中找到 HR、售后、销售或项目机器人。这个机器人就是业务入口。
|
||||
|
||||
#### 场景资产
|
||||
|
||||
dsh-im 是最容易获得真实交互环境的一层:
|
||||
|
||||
- 当前是私聊还是群聊;
|
||||
- 当前发言人是谁;
|
||||
- 消息是否处于线程、回复或 @ 关系中;
|
||||
- 用户是在手机即时通信、团队协作还是社区频道中;
|
||||
- 用户与机器人此前完成了哪些交互。
|
||||
|
||||
这些信息不是模型可以仅从一段消息正文中可靠推断的,却会直接影响 Agent 如何理解和回应用户。
|
||||
|
||||
#### 主动触达与结果交付资产
|
||||
|
||||
dsh-im 还有机会成为 Harness Agent、定时任务和业务事件重新找到用户的通道:
|
||||
|
||||
```text
|
||||
时间到了
|
||||
任务完成了
|
||||
业务状态变化了
|
||||
风险或异常出现了
|
||||
Agent 需要人类决策了
|
||||
↓
|
||||
dsh-im 把信息交付给用户所在的沟通场景
|
||||
```
|
||||
|
||||
这项资产让用户不必停留在浏览器、Harness 或某个长任务页面中等待。用户可以把工作委托给 Agent,离开当前界面,再在结果准备好或需要参与时自然地回到流程中。
|
||||
|
||||
主动触达的核心价值不是“机器人会发一条通知”,而是:
|
||||
|
||||
> **Agent 的工作不再必须与用户当前是否在线、是否停留在任务界面绑定。**
|
||||
|
||||
#### 交互闭环资产
|
||||
|
||||
dsh-im 不只能把一句话送进模型,也不只是把一个结果推给用户,还有机会承载完整的交互循环:
|
||||
|
||||
```text
|
||||
用户需求或外部事件
|
||||
→ Agent 理解、执行或等待条件
|
||||
→ dsh-im 主动交付进度、结果或决策请求
|
||||
→ 用户查看、追问、确认、选择或审批
|
||||
→ Agent 根据用户反馈继续工作
|
||||
→ 最终以渠道原生形式完成交付
|
||||
```
|
||||
|
||||
真正的 Agent 产品并不只是“问一句、答一句”。追问、确认、授权、进度、中断、重试和结果交付都发生在交互层。主动消息也不是一次单向推送的终点,而可以是下一轮交互的起点。这是 dsh-im 相对于普通 API 连接器和通知服务更有长期价值的部分。
|
||||
|
||||
### 7.3 Issue #58 为什么是一个转折点
|
||||
|
||||
五个字段并不足以单独支撑一个庞大的产品故事。它们的重要性在于,它们是 dsh-im 第一次显式地尝试将“平台事件”翻译成“Agent 交互上下文”。
|
||||
|
||||
```text
|
||||
只做协议转换:
|
||||
平台消息 → 用户正文 → Harness
|
||||
|
||||
开始理解交互场景:
|
||||
平台消息 → 统一上下文 + 用户正文 → Harness
|
||||
```
|
||||
|
||||
前者的核心指标是“能否接入更多渠道”;后者还需要关心:
|
||||
|
||||
- Agent 是否获得了正确的场景事实;
|
||||
- 不同渠道的语义是否统一;
|
||||
- 机器人是否能表达自己的业务入口和交互策略;
|
||||
- Agent 结果是否以符合当前渠道的方式交付;
|
||||
- 一次任务是否能在消息场景中形成完整闭环。
|
||||
|
||||
因此,Issue #58 的直接开发量可以很小,但它带来的认知变化很大:
|
||||
|
||||
> **dsh-im 的中心问题可以从“如何接入 Harness”,扩展为“如何让用户在所在的沟通场景中有效地使用 Harness Agent”。**
|
||||
|
||||
### 7.4 Issue #65 为什么又是一个转折点
|
||||
|
||||
Issue #63 最初提出“任务完成后通过 IM 渠道发送通知”,用户显式关注某个 Session 可以解决其中一类场景。Issue #65 进一步提出的是更通用的产品命题:不仅是用户显式关注的 Harness Session,定时任务、看板、长任务和其他宿主能力是否也能借助 dsh-im 触达用户。
|
||||
|
||||
它把 dsh-im 的产品关系从:
|
||||
|
||||
```text
|
||||
用户先发来一条消息
|
||||
→ dsh-im 把消息送给 Harness
|
||||
→ dsh-im 对原消息进行回复
|
||||
```
|
||||
|
||||
扩展为:
|
||||
|
||||
```text
|
||||
任务、时间或业务事件发生
|
||||
→ 结果或决策请求需要找到人
|
||||
→ dsh-im 把它交付到用户所在的 IM 场景
|
||||
→ 用户可以直接反馈并继续流程
|
||||
```
|
||||
|
||||
因此,Issue #65 的最大价值不是“可以定时发一条消息”,而是它首次明确了:
|
||||
|
||||
> **dsh-im 可以是整个 Harness 宿主中的统一用户触达与结果交付层。**
|
||||
|
||||
这使 dsh-im 从被动问答机器人,走向可以支撑异步 Agent、事件驱动工作流和持续用户服务的交互基础设施。
|
||||
|
||||
### 7.5 Issue #58 与 Issue #65 共同形成一进一出
|
||||
|
||||
两个 Issue 的价值不是彼此独立的:
|
||||
|
||||
```text
|
||||
Issue #58:理解用户
|
||||
用户 → 渠道和场景上下文 → dsh-im → Harness Agent
|
||||
|
||||
Issue #65:重新找到用户
|
||||
Harness Agent / 定时任务 / 业务事件 → dsh-im → 用户
|
||||
|
||||
用户收到结果后继续回复
|
||||
→ 又进入带有场景上下文的新一轮交互
|
||||
```
|
||||
|
||||
当输入上下文、Agent 执行、主动交付和用户反馈能够首尾相接时,dsh-im 才真正从“接入通道”进入“交互闭环”。
|
||||
|
||||
### 7.6 与 Harness 和业务系统的边界
|
||||
|
||||
定位升级不意味着 dsh-im 要把所有 Agent 能力都实现一遍。更有长期稳定性的分层是:
|
||||
|
||||
```text
|
||||
用户与沟通场景
|
||||
↓
|
||||
dsh-im
|
||||
多渠道接入、交互上下文、业务入口、会话路由、结果交付
|
||||
↓
|
||||
DeepSeek Harness
|
||||
Agent 运行、模型调用、工具编排、Session 状态
|
||||
↓
|
||||
业务服务
|
||||
可信身份、权限校验、数据访问、事务执行、审计
|
||||
```
|
||||
|
||||
dsh-im 应该深挖的是“离用户近”才能做好的能力,而不是无限扩张边界。特别是:
|
||||
|
||||
- 不替代 Harness 运行和编排 Agent;
|
||||
- 不把提示词中的来源字段当作身份认证;
|
||||
- 不让模型决定用户的实际权限;
|
||||
- 不在 dsh-im 中保存数据库用户名和密码;
|
||||
- 不把通用数据库访问、业务事务和审计都变成插件职责。
|
||||
|
||||
边界越清晰,dsh-im 作为交互网关的价值反而越突出。
|
||||
|
||||
### 7.7 定位升级对产品建设的启发
|
||||
|
||||
如果认可这一定位,未来的产品建设重心也会发生变化:
|
||||
|
||||
| 原来更关心 | 未来还需要关心 |
|
||||
| --- | --- |
|
||||
| 接入了多少渠道 | 不同渠道的交互语义是否统一 |
|
||||
| 消息能否收发 | Agent 是否拿到理解场景所需的上下文 |
|
||||
| 机器人的连接配置 | 机器人的业务入口、Agent 能力和交互策略 |
|
||||
| 文本问答是否成功 | 追问、确认、进度和结果交付是否形成闭环 |
|
||||
| 用户发来消息后如何回复 | 任务、时间和业务事件如何在需要时找到用户 |
|
||||
| 回答是否成功返回 | 用户收到结果后是否能参与决策并推动任务继续 |
|
||||
| 对 Harness 接口的适配 | 对用户在真实场景中使用 Agent 的整体体验 |
|
||||
|
||||
机器人卡片也可能逐步从“连接参数表单”变成“Agent 业务入口配置”,除了渠道凭据之外,还能显式表达:
|
||||
|
||||
- 这个机器人面向谁;
|
||||
- 它代表哪个部门、品牌或业务线;
|
||||
- 它使用哪个 Agent Preset;
|
||||
- 它向 Agent 提供哪些交互上下文;
|
||||
- 它希望 Agent 如何处理群聊、私聊和渠道差异;
|
||||
- 它在哪些场景中承载主动触达和异步结果交付;
|
||||
- 它如何向用户展示进度和交付结果。
|
||||
|
||||
这些都可以从 Issue #58 所开始的“消息来源上下文”和 Issue #65 所开始的“宿主主动触达”向外渐进演化,但不必一次全部实现。
|
||||
|
||||
## 8. dsh-im 的价值层次与复利效应
|
||||
|
||||
### 8.1 第一层:连接效率
|
||||
|
||||
dsh-im 最初的价值是用一个插件统一管理多种 IM 渠道,让机器人更方便地接入 DeepSeek Harness。
|
||||
|
||||
这层价值降低了渠道接入、凭据管理、会话映射和结果回传的重复成本。它是 dsh-im 的基础,也是后续所有价值能够跨渠道复用的前提。
|
||||
|
||||
### 8.2 第二层:用户交互价值
|
||||
|
||||
当 dsh-im 开始理解渠道、机器人、发言人、私聊、群聊、回复和线程时,它提供的就不只是连接,而是一个比通用 AI 界面更贴近用户现场的交互环境。
|
||||
|
||||
这层价值解决的是:
|
||||
|
||||
- 用户不需要离开已有沟通工具;
|
||||
- Agent 不再只看到一段失去场景的文字;
|
||||
- 不同机器人可以承载不同的业务入口和交互策略;
|
||||
- Agent 的进度、问题、审批和结果可以以符合渠道的方式呈现。
|
||||
|
||||
### 8.3 第三层:异步 Agent 与主动服务价值
|
||||
|
||||
主动触达让 Agent 工作与用户当前是否在线解耦。用户可以委托长时间任务、离开界面,在结果完成、条件满足、状态变化或需要人类决策时被自然地唤回。
|
||||
|
||||
它的价值可以逐层深化:
|
||||
|
||||
```text
|
||||
通知:把一条信息送达用户
|
||||
→ 异步交付:把 Agent 的长任务结果送回用户
|
||||
→ 事件驱动交互:业务事件发生后请求用户行动
|
||||
→ 持续服务:Agent 长期关注目标,在合适时机主动回到用户身边
|
||||
```
|
||||
|
||||
从“用户使用 Agent”到“Agent 持续服务用户”,是 Issue #65 所打开的最大想象空间。
|
||||
|
||||
### 8.4 第四层:生态公共能力价值
|
||||
|
||||
如果每个定时任务、看板、业务插件或 Agent 都要分别理解每个 IM 渠道,业务能力与渠道之间会形成乘法关系:
|
||||
|
||||
```text
|
||||
没有统一交互层:
|
||||
业务能力数 × 渠道数 = 持续增长的重复建设
|
||||
|
||||
通过 dsh-im:
|
||||
业务能力 → dsh-im → 多渠道用户
|
||||
```
|
||||
|
||||
一旦 dsh-im 成为统一交互层:
|
||||
|
||||
- 新渠道可以为已有业务能力新增用户入口;
|
||||
- 新交互能力可以被已有渠道共同复用;
|
||||
- 新宿主能力可以借助已有机器人找到用户;
|
||||
- 业务方不需要把交互、渠道和 Agent 编排混成同一套实现。
|
||||
|
||||
这使 dsh-im 从一个功能插件逐步具备平台型价值。
|
||||
|
||||
### 8.5 含金量来自不可轻易替代的交互资产
|
||||
|
||||
dsh-im 的含金量不应只由代码量、渠道数或功能数量衡量。真正逐渐难以替代的是:
|
||||
|
||||
- 对不同渠道真实交互语义和能力边界的长期理解;
|
||||
- 把九种平台事件转换成统一 Agent 上下文的能力;
|
||||
- 机器人、业务入口、会话场景与 Agent 能力之间的关系;
|
||||
- 让进度、追问、审批、产物和结果在渠道中原生呈现的用户体验;
|
||||
- 让任务和业务事件能够找到正确用户并继续交互的触达关系;
|
||||
- 在模型概率性与身份、权限、事务确定性之间建立的清晰边界。
|
||||
|
||||
当这些资产形成后,替代 dsh-im 就不再只是重新调用几个渠道 API,而是需要重新建设一套用户入口、场景语义、主动触达、原生交付和交互闭环。
|
||||
|
||||
## 9. 未来业务想象力
|
||||
|
||||
### 9.1 群聊中的团队协作 Agent
|
||||
|
||||
来源上下文可以让 Agent 有机会理解群内人物和发言归属:
|
||||
|
||||
- 谁提出了需求;
|
||||
- 谁承诺了任务;
|
||||
- 谁表达了不同意见;
|
||||
- 谁需要补充材料;
|
||||
- 会议结束后每个人分别有哪些行动项。
|
||||
|
||||
未来可以从普通问答机器人演进为群聊中的讨论整理者和协作助手。
|
||||
|
||||
当结合主动触达时,它还可以在会议后追踪行动项,在截止时间前提醒相关成员,在信息不足时重新回到群里请求补充,从“整理讨论”走向“推动协作完成”。
|
||||
|
||||
### 9.2 多渠道客户服务与销售入口
|
||||
|
||||
企业可以在微信、企业微信、WhatsApp、Telegram、Slack 等渠道部署不同机器人:
|
||||
|
||||
- 来源上下文帮助 Agent理解当前渠道和客户入口;
|
||||
- `senderId` 可以成为后续统一客户身份映射的原始键;
|
||||
- `botId` 可以映射品牌、地区、产品线或服务阶段;
|
||||
- 同一套 Agent 能力可以服务多个渠道入口;
|
||||
- 客户状态变化、工单更新或应跟进时,Agent 可以回到客户原本所在的沟通场景。
|
||||
|
||||
长期演进路径可以是:
|
||||
|
||||
```text
|
||||
渠道身份
|
||||
→ 统一客户身份
|
||||
→ 客户资料和业务状态
|
||||
→ 受限的业务工具与工作流
|
||||
```
|
||||
|
||||
### 9.3 企业内部员工服务
|
||||
|
||||
当渠道身份能够映射企业身份后,可以形成:
|
||||
|
||||
- 员工在飞书或企业微信中查询个人信息;
|
||||
- 群聊中把任务和行动项关联到成员;
|
||||
- 私聊中继续处理员工此前提出的问题;
|
||||
- 根据部门和角色进入不同业务服务;
|
||||
- 审批到达、截止时间临近或员工需要补充资料时,通过日常 IM 主动触达。
|
||||
|
||||
来源字段本身不等于可信企业身份,但它是建立身份映射所需的入口信息。
|
||||
|
||||
### 9.4 多机器人业务矩阵
|
||||
|
||||
未来一个 dsh-im 实例可能管理一组业务机器人:
|
||||
|
||||
```text
|
||||
渠道
|
||||
└─ 机器人
|
||||
└─ 业务角色
|
||||
└─ Agent Preset
|
||||
└─ 工具和业务服务
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
| 机器人 | 业务定位 | 可能使用的能力 |
|
||||
| --- | --- | --- |
|
||||
| HR 机器人 | 员工服务入口 | 假期、招聘、员工资料工具 |
|
||||
| 销售机器人 | 客户与商机入口 | CRM、合同、报价工具 |
|
||||
| 售后机器人 | 服务入口 | 工单、订单、设备工具 |
|
||||
| 社区机器人 | 社区运营入口 | FAQ、内容检索、反馈收集 |
|
||||
|
||||
在这个模型中:
|
||||
|
||||
```text
|
||||
Bot = 业务入口
|
||||
Agent Preset = 能力组合
|
||||
消息来源上下文 = 当前参与者与交流场景
|
||||
主动触达 = 任务、时间或业务事件找到用户
|
||||
业务工具 = 确定性操作
|
||||
权限服务 = 实际授权边界
|
||||
```
|
||||
|
||||
### 9.5 异步 Agent 与事件驱动服务
|
||||
|
||||
过去的聊天机器人默认用户在线等待,而很多高价值 Agent 任务天然是异步的:
|
||||
|
||||
- 深度研究、数据分析和代码开发;
|
||||
- 长时间巡检、监控和等待条件;
|
||||
- 多步骤工作流与外部系统处理;
|
||||
- 需要在中途等待用户确认或审批的任务;
|
||||
- 长期关注某个目标,条件满足后再行动的任务。
|
||||
|
||||
如果 Agent 完成工作后无法回到用户身边,它的异步能力就很难转化为完整的用户价值。主动触达让用户可以真正把工作委托给 Agent,而不是一直陪着 Agent 工作。
|
||||
|
||||
主动消息又可以自然地成为新一轮交互的起点。例如,“报告已生成”只是通知,而“报告已生成,是否现在发送给客户?”会把用户直接带入下一个决策节点。
|
||||
|
||||
这使 dsh-im 可以支撑从“问答”到“委托”、从“通知”到“事件驱动交互”的产品跃迁。
|
||||
|
||||
### 9.6 dsh-im 作为上下文与交互中间层
|
||||
|
||||
dsh-im 当前的核心职责是连接九个 IM 渠道与 Harness。来源上下文能力揭示了一个更广的产品方向:
|
||||
|
||||
> **不仅传递消息,还把平台世界中的上下文翻译成 Agent 可以消费的统一语义,并把 Agent 的进度、结果和交互请求送回用户所在的场景。**
|
||||
|
||||
未来可扩展的上下文包括:
|
||||
|
||||
```text
|
||||
threadId
|
||||
replyTo
|
||||
mentionedUsers
|
||||
groupName
|
||||
messageLanguage
|
||||
botRole
|
||||
businessLine
|
||||
tenantId
|
||||
clientCapabilities
|
||||
```
|
||||
|
||||
这些字段不应无边界地全部塞入提示词,但它们展示了统一上下文模型的演进空间。与之对应,主动触达也不应被只理解为“主动发文本”,而应被理解为将进度、产物、决策请求和后续交互送回正确用户场景的交付语义。
|
||||
|
||||
## 10. 模型理解、主动触达与确定性处理的边界
|
||||
|
||||
来源信息通过用户提示词传递时,dsh-im 和 Harness 只能保证:
|
||||
|
||||
```text
|
||||
dsh-im:正确提取并写入来源数据
|
||||
Harness:把消息保存进 Session 并提供给模型
|
||||
模型:有机会理解和使用,但没有确定性保证
|
||||
```
|
||||
|
||||
因此,价值必须按使用场景区分:
|
||||
|
||||
| 使用场景 | 是否适合依赖模型理解 |
|
||||
| --- | --- |
|
||||
| 使用昵称称呼当前用户 | 适合,失败影响较小 |
|
||||
| 调整私聊或群聊表达方式 | 适合作为体验增强 |
|
||||
| 群聊总结中的发言归属 | 可以辅助,重要结果应允许校对 |
|
||||
| 判断用户权限 | 不适合,必须由代码处理 |
|
||||
| 选择数据库租户或扩大数据范围 | 不适合,必须由服务端处理 |
|
||||
| Session 隔离与消息路由 | 不适合,必须继续由 dsh-im/Harness 代码处理 |
|
||||
|
||||
主动触达也有同样的边界。其价值是让一个已有合理关系的任务、事件或服务在合适时机找到用户,而不是让模型可以凭提示词自行决定向任意用户发送任意消息。
|
||||
|
||||
```text
|
||||
模型可以帮助生成表达和理解反馈
|
||||
|
||||
谁有权发起触达
|
||||
触达哪个用户或会话
|
||||
是否符合用户预期
|
||||
是否需要同意、限频和审计
|
||||
→ 必须是可信业务关系和确定性规则
|
||||
```
|
||||
|
||||
否则,“主动服务”很容易退化为“主动打扰”,反而会伤害 dsh-im 最重要的用户信任资产。
|
||||
|
||||
即使使用 Agent Preset 或系统提示词,也只能提高模型遵守规则的概率,不能形成权限保证。
|
||||
|
||||
如果未来需要确定性业务能力,正确分层应是:
|
||||
|
||||
```text
|
||||
来源上下文
|
||||
→ 帮助模型理解和表达
|
||||
|
||||
dsh-im/身份服务
|
||||
→ 解析可信机器人和用户身份
|
||||
|
||||
Agent Preset
|
||||
→ 决定 Session 的工具集合
|
||||
|
||||
业务工具/API
|
||||
→ 校验权限并执行确定性操作
|
||||
```
|
||||
|
||||
## 11. 可能的产品演进
|
||||
|
||||
从整体产品视角看,dsh-im 的演进路径可以概括为:
|
||||
|
||||
```text
|
||||
多渠道连接器
|
||||
→ 场景感知的消息上下文网关
|
||||
→ 可主动触达用户的结果交付层
|
||||
→ 渠道原生的 Agent 交互闭环
|
||||
→ 企业或个人的 AI 业务入口
|
||||
```
|
||||
|
||||
以下阶段不是一次性承诺,也不代表必须按顺序实施,而是用于说明每一层新价值是如何在上一层基础上生长的。
|
||||
|
||||
### 阶段一:多渠道 Agent 入口
|
||||
|
||||
用户可以在已有 IM 中使用 Harness Agent,不需要为每个渠道重复建设一套对话入口。
|
||||
|
||||
核心价值是:**让 Harness 触手可及。**
|
||||
|
||||
### 阶段二:场景感知的上下文网关
|
||||
|
||||
dsh-im 不再只搬运消息正文,而开始向 Agent 表达谁、在哪里、通过哪个入口和处于什么交互关系。Issue #58 是这一阶段的一个最小价值切片。
|
||||
|
||||
核心价值是:**让 Agent 理解用户所在的真实场景。**
|
||||
|
||||
### 阶段三:异步结果交付与主动触达
|
||||
|
||||
任务完成、条件满足、业务状态变化或需要人类决策时,dsh-im 可以让已有用户关系中的服务重新回到用户身边。Issue #65 是这一阶段的一个最小价值切片。
|
||||
|
||||
核心价值是:**让用户可以真正把工作委托给 Agent,而不是一直在线等待。**
|
||||
|
||||
### 阶段四:事件驱动的 Agent 交互闭环
|
||||
|
||||
主动送达的信息可以成为新一轮交互的起点。用户能够确认、追问、审批或修改,Agent 则根据反馈继续执行,直到完成结果交付。
|
||||
|
||||
核心价值是:**从单轮问答走向持续完成任务的人机协作。**
|
||||
|
||||
### 阶段五:企业或个人的 AI 业务入口
|
||||
|
||||
机器人逐步承载部门、品牌、业务线或长期个人服务的身份;Agent Preset 承载能力组合;业务服务承载可信身份、权限、数据和事务。
|
||||
|
||||
核心价值是:**让 Agent 不只是通用对话能力,而是成为用户进入真实业务和持续服务的入口。**
|
||||
|
||||
前三个阶段主要深挖 dsh-im 天然接近用户的价值;后两个阶段需要 Harness 和业务基础设施共同参与,不能被简化为某个提示词字段或一次主动发送。
|
||||
|
||||
## 12. 即使不实施仍然成立的启发
|
||||
|
||||
这次讨论至少确认了以下领域认知:
|
||||
|
||||
1. **渠道机器人不是 Agent。** 它是平台入口和消息收发主体。
|
||||
2. **Bot 可以成为业务身份。** 一个机器人可能代表部门、品牌、租户或业务线。
|
||||
3. **Agent Preset 是能力组合。** 它决定新 Session 的提示词、工具和 Skills,不是当前消息的来源信息。
|
||||
4. **来源上下文描述事实。** 它回答谁、在哪里、通过哪个入口发言。
|
||||
5. **上下文使用策略描述期望行为。** 它告诉模型希望怎样理解来源事实,但仍然是概率性的提示词行为。
|
||||
6. **权限必须由代码实施。** Agent 不能依据提示词中的元数据自行授予数据库或业务权限。
|
||||
7. **dsh-im 可以承担语义翻译。** 插件不仅能转发消息,也能把九个平台的异构事件转换成统一 Agent 上下文。
|
||||
8. **插件是技术形态,不是产品上限。** dsh-im 可以不修改、不替代 Harness,同时成为 Harness 面向真实用户的关键交互层。
|
||||
9. **入口、场景、触达和闭环是 dsh-im 的长期资产。** 这些能力来自项目天然接近用户,也是单纯模型层或工具编排层不容易替代的价值。
|
||||
10. **更大的定位仍然需要清晰边界。** dsh-im 负责交互和上下文,Harness 负责 Agent 运行和工具编排,业务服务负责身份、权限、数据和事务。
|
||||
11. **主动消息不是交互的终点。** 它可以唤回用户,承接用户的追问、确认或审批,然后让 Agent 继续工作。
|
||||
12. **异步 Agent 需要结果交付层。** 如果完成后无法回到用户身边,Agent 的长任务、监控和等待能力就难以形成完整用户价值。
|
||||
13. **交互能力与渠道能力会相互放大。** 新渠道为所有已有能力增加入口,新能力又可以通过已有渠道贴近用户。
|
||||
14. **用户信任是主动触达的前提。** 只有在与用户预期一致、时机合理且对用户真正有价值时,主动触达才是服务而不是打扰。
|
||||
|
||||
这些认知可以独立指导未来的群聊、引用消息、线程、异步任务、主动触达、业务机器人、身份映射和工具授权设计,因此不依赖任何一个 Issue 是否立即进入开发。
|
||||
|
||||
## 13. 价值落地时必须保持的判断
|
||||
|
||||
首先必须区分**当前价值切片**和**长期产品方向**:
|
||||
|
||||
| 讨论 | 当前可验证价值 | 它所启发的长期方向 |
|
||||
| --- | --- | --- |
|
||||
| Issue #58 | 群聊发言人归属和私聊/群聊场景感知 | 统一消息上下文与机器人交互策略 |
|
||||
| Issue #65 | 定时任务、长任务和宿主事件能够交付结果 | 异步 Agent、主动服务与事件驱动交互闭环 |
|
||||
|
||||
长期想象力说明了方向为什么值得重视,但不能反过来替任何一个当前方案自动证明价值。每个价值切片仍应单独回答:
|
||||
|
||||
- 它是否让用户更容易找到 Agent,或让 Agent 更容易回到用户身边;
|
||||
- 它是否让 Agent 对用户场景的理解更准确;
|
||||
- 它是否缩短了“Agent 产生结果”到“用户收到并采取行动”之间的距离;
|
||||
- 它是否能跨多个渠道和业务入口形成可复用的交互语义;
|
||||
- 它是否带来用户可感知的闭环,而不只是增加内部能力;
|
||||
- 它是否尊重用户预期和信任,避免将主动服务变成主动打扰;
|
||||
- 它是否保持了 dsh-im、Harness 和业务服务的职责边界。
|
||||
|
||||
同时,以下表述仍然不应被夸大:
|
||||
|
||||
- 提示词元数据不是可信身份、权限或租户路由;
|
||||
- 主动触达不意味着模型可以任意联系用户;
|
||||
- dsh-im 成为交互网关,不意味着它要替代 Harness 或业务系统;
|
||||
- 五个来源字段和一次主动送达都只是价值入口,不是完整产品终态。
|
||||
|
||||
## 14. 总结
|
||||
|
||||
Issue #58 与 Issue #65 的直接价值切片都可以很小,但它们共同暴露出的产品方向很大:
|
||||
|
||||
```text
|
||||
不只是把 IM 机器人接入 Harness,
|
||||
而是开始定义 Agent 如何进入用户所在的真实沟通场景。
|
||||
```
|
||||
|
||||
它们分别补上了 Agent 用户交互的两个关键方向:
|
||||
|
||||
```text
|
||||
Issue #58:让 Agent 理解用户与场景
|
||||
Issue #65:让任务与 Agent 在需要时重新找到用户
|
||||
```
|
||||
|
||||
它们组合后,dsh-im 开始具备一套完整的长期价值链:
|
||||
|
||||
```text
|
||||
用户通过渠道和业务机器人进入
|
||||
→ dsh-im 提供场景上下文
|
||||
→ Harness Agent 理解、执行和等待
|
||||
→ dsh-im 在合适时机交付进度、结果或决策请求
|
||||
→ 用户回复、确认、审批或追问
|
||||
→ Agent 继续工作并完成闭环
|
||||
```
|
||||
|
||||
因此,dsh-im 的含金量不是来自功能简单堆叠,而是来自一组会相互放大的资产:
|
||||
|
||||
```text
|
||||
多渠道 × 业务入口 × 场景上下文 × 主动触达 × 交互闭环
|
||||
```
|
||||
|
||||
Harness 更理解 Agent 如何运行,业务服务更理解身份、权限、数据和事务,而 dsh-im 可以专注于一个同样稀缺的问题:
|
||||
|
||||
> **用户如何在自己所在的沟通场景中遇见 Agent、被 Agent 理解、收到 Agent 的工作结果,并与 Agent 持续完成事情。**
|
||||
|
||||
这也是 dsh-im 更准确的产品定位:
|
||||
|
||||
> **从多渠道接入插件,走向连接用户沟通场景与 Harness Agent 能力的交互网关。**
|
||||
195
docs/方案/上下文增强-验收记录.md
Normal file
195
docs/方案/上下文增强-验收记录.md
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# 上下文增强验收记录
|
||||
|
||||
记录日期:2026-08-29。
|
||||
|
||||
当前结论:自动检查、下列界面检查及实际设置保存/回读已通过。十一项真实场景中,8/11 具有主任务独立、完整的三轮证据:Telegram、飞书、微信、企业微信、WhatsApp、Slack、Discord 私聊,以及飞书 DeepSeek大会群聊;原设置均已确认恢复。QQ、钉钉私聊、钉钉指定群聊由用户明确确认已自行测试,并要求本任务不再重复操作。当前没有剩余客户端操作;独立证据与用户确认仍分别记录,不合并称为 11/11 独立通过。本记录不包含账号原始 ID、Session ID、昵称、手机号或私聊正文。
|
||||
|
||||
## 自动验证
|
||||
|
||||
| 项目 | 已验证结果 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 完整检查 | `npm run check` 退出码 0;1893/1893 测试通过,失败、取消、跳过均为 0 | [完整日志](/tmp/dsh-im-context-50ivdt/check-complete.log) |
|
||||
| 构建与包校验 | Client、Host 构建成功;包产物校验通过 | 同上 |
|
||||
| 真正升级前基线差分 | 204/204 对比一致;306 次 Bridge 实例运行、969 次 `accept` 调用;主任务已重新运行确认 | [验证脚本](/tmp/dsh-im-context-baseline.Fepkat/compare-off.mjs)、[原始两版本观测](/tmp/dsh-im-context-baseline.Fepkat/off-differential-observations.json) |
|
||||
|
||||
差分基线是提交 `5928b33057f242d0c199cc901d62aa70da7287f8` 的真实源码,经 `git archive` 提取到 `/tmp/dsh-im-context-baseline.Fepkat`;对照为本次实现的工作树,未以当前代码的「无 provider」状态作为基线。两版本共用现有 `node_modules`,验证脚本复用 `test/channels/shared/context-enhancement-bridges.test.mjs` 的 fixture。
|
||||
|
||||
覆盖范围:九渠道私聊、除微信外的八渠道群聊;每种场景比较当前「群聊与私聊均关闭」及「仅另一会话范围开启」与旧版本。场景包含文本、图片、文件、混合消息、批量消息及本地命令;媒体场景还验证重复消息不重复处理、后续消息复用 Session。比较 Harness/平台调用顺序、提示内容、媒体及文件字节、可序列化的 `ask` 参数、Session/seen 状态,并确认关闭时不读取增强专用来源字段。
|
||||
|
||||
验证限制:
|
||||
|
||||
- 运行了九个实际 Bridge,以及 Slack、Telegram、Discord、WhatsApp 四个实际 runtime normalizer;其他五渠道从 fixture 的原始事件进入 Bridge,未启动真实网络连接器。
|
||||
- 回调参数比较存在性,AbortSignal 比较取消状态;未进行流式回调、审批/问答往返的跨提交差分。群聊命令按旧行为比较,不假定与私聊行为相同。
|
||||
- 钉钉、飞书的混合 fixture 为文字加两张图片,不含同一消息内的文件;文件独立场景已验证。
|
||||
- 上述证据路径位于临时目录,可能被系统清理;本文件持久保留验证结论、方法和边界。
|
||||
|
||||
## 实际界面检查
|
||||
|
||||
以下由主任务在实际浏览器中检查;保存回读仅覆盖表中明确列出的场景,不等同于 IM 消息链路验收。
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 默认群聊、私聊开关 | 两项均关闭 |
|
||||
| 默认来源字段 | 仅“发送者标识”(`senderId`)选中 |
|
||||
| 默认增强提示词 | 空;问号帮助提供使用说明和示例 |
|
||||
| 本地草稿取消、Esc 关闭 | 不保存草稿;Esc 只关闭增强子弹窗 |
|
||||
| 键盘焦点 | Tab 焦点限制在弹窗内,末项回到关闭按钮 |
|
||||
| 微信群聊开关 | 禁用,并说明不支持群聊 |
|
||||
| 窄屏布局 | 360px、320px 宽度均无横向溢出 |
|
||||
| 实际保存与刷新回读(飞书,双关闭) | 取消「渠道」字段、清空提示词并保存成功;刷新页面后两开关仍关闭、仅「渠道」未选、提示词长度为 0 |
|
||||
| 测试后还原(飞书) | 恢复原来的五字段和原提示词并保存;重新打开后两开关仍关闭,五字段全选,提示词与测试前逐字一致 |
|
||||
| 实际设置保存(Telegram) | 四轮验收中实际保存开关、字段选择及空提示词,结果由真实 Host 历史核对 |
|
||||
| 测试后还原(Telegram) | 已保存恢复原始双关闭、五字段全选及原 253 字符提示词;重新打开逐字比对,`restoredExactly=true` |
|
||||
| 飞书私聊阶段还原 | 第三轮前已通过 UI 确认恢复原双关闭、五字段全选及原 253 字符提示词,内容完全一致 |
|
||||
| 飞书群聊阶段还原 | 群聊开启轮后已保存恢复原双关闭、五字段全选及原 253 字符提示词;重新打开 UI,完整配置 JSON 比较相等为 true,再发送群聊关闭轮 |
|
||||
| 测试后还原(微信) | 已恢复原设置,UI 完整配置 JSON 比较相等为 true |
|
||||
| 测试后还原(企业微信) | 已通过 UI 完全恢复原配置,完整比较为 true;三轮收发及完成明细独立核验通过 |
|
||||
| 测试后还原(WhatsApp) | 已恢复原配置,UI 完整配置 JSON 比较完全相等为 true |
|
||||
| 测试后还原(Slack) | 已精确恢复原双关闭、五字段全选及原 guidance |
|
||||
| 测试后还原(Discord) | 已精确恢复原双关闭、五字段全选及原 guidance |
|
||||
| 测试后还原(QQ) | 已精确恢复测试前配置;因用户已自行测试,不再追加标准第三轮 |
|
||||
|
||||
## 本地运行环境
|
||||
|
||||
本地 Harness 原进程已正常退出,随后以原 Node 版本、工作目录和启动参数重新启动,加载工作区构建出的插件。本地只读健康检查通过;会话数量重启前后均为 256,检查时运行中会话为 0。上述重启与飞书保存回读阶段未修改 Harness 代码、凭据或既有会话,也未主动开启任何机器人的上下文增强。后续 Telegram 四轮测试仅通过实际界面调整并恢复该机器人的增强设置,未修改 Harness、系统权限或凭据。
|
||||
|
||||
后续重新打开界面时,发现另一个飞书机器人的现有状态已为「仅群聊」。这是用户自己的现有配置,保持不动;保存回读及本轮收发测试使用另一个原为双关闭的机器人,没有用旧的「全部关闭」观察覆盖用户后续调整。测试机器人的「仅群聊」仅为独立的临时测试状态,现已恢复其原双关闭配置,未覆盖用户原有的群聊开启配置。
|
||||
|
||||
在旧进程上尝试保存时,新界面收到旧后端的「Unknown Feishu endpoint」响应,未保存配置,草稿随后取消。重启期间的一次页面重载随后返回浏览器 URL 策略拦截,最初被误判为设置页持续受限。
|
||||
|
||||
补充排查已定位原因:本机应用日志在 `2026-08-28T18:37:53Z` 记录,被阻止的实际 URL 是 `data:text/html;charset=utf-8,...`,其页面标题为「This site can't be reached」,不是 Harness 的 HTTP 地址。服务尚未就绪时产生的错误页触发了拦截;服务恢复健康后,在同一自动化浏览器中用原地址 `http://127.0.0.1:3080/` 正常打开了设置页和 IM 机器人页面。未访问被拦的错误页、切换地址绕行或修改安全配置。因此不再将该事件视为持续的设置页访问阻碍;随后单独完成了上述实际保存与刷新回读检查。
|
||||
|
||||
## 真实平台验收
|
||||
|
||||
用户已明确同意真实发送。九个 IM 私聊及两个指定群聊共十一项中,8/11 具有主任务独立完整的三轮证据;QQ、钉钉私聊、钉钉指定群聊由用户确认已自行测试,并要求停止本任务的重复客户端操作。整体没有剩余客户端操作,但用户确认不能替代主任务独立三轮证据,因此两种结论在下表分别标注。
|
||||
|
||||
证据口径与操作边界:
|
||||
|
||||
- 已通过客户端列表确认九个 IM 客户端均在运行。仅读取插件已有的本地会话映射,没有查询平台用户资料 API。
|
||||
- WhatsApp 本人聊天的既有会话历史预览与当前本地映射唯一匹配,已消除此前两个私聊 Session 的歧义。准备阶段曾显示同步暂停提示,后续三轮真实收发均通过,不以该提示推断插件失败。
|
||||
- 已准备只读、脱敏的 [会话验收脚本](/tmp/dsh-im-context-50ivdt/live-context-check.mjs):按完整测试标记核对真实 `user/message`、来源 JSON 字段、指导标签和原文,不输出字段值或其他聊天正文。54 项纯 fixture 检查通过;准备阶段读取指定飞书 Session 时,测试标记尚不存在,该结果仅证明核验链路可用。后续实际发送的脚本核验结果另见下表,不用准备阶段结果替代收发验收。
|
||||
- Slack、Discord 已完成标准关闭、开启、再次关闭三轮,并恢复原设置;此前的输入障碍已解决,不再影响验收结论。
|
||||
- QQ 的两条主任务证据均有完整完成事件;开启轮因客户端滞后成为两次测试文本连续拼接的复合输入,不能冒充标准三轮独立样本。用户随后确认已自行测试并要求不再操作,主任务未追加第三轮。
|
||||
- 钉钉私聊及指定群聊由用户确认已自行测试并要求不再操作。群聊另有一次平台可见消息形成两条独立 Harness 入站的主任务观测;不能严格证明重复原因,也不将其当作干净、唯一的标准样本。
|
||||
- 钉钉另一个机器人的用户原有「仅群聊」配置未被覆盖。梁梁群测试过程中,既有提及状态与测试正文合并成平台可见消息,因此该消息及其双入站结果只作为非标准观测,不宣称保留了原草稿状态;本记录不抄录正文。未修改 Harness、系统权限或凭据。
|
||||
|
||||
| 渠道 | 场景 | 状态 | 结论/问题 |
|
||||
| --- | --- | --- | --- |
|
||||
| 微信 | 私聊 | 通过 | 三轮真实收发及独立历史复核通过;原配置已确认恢复 |
|
||||
| 企业微信 | 私聊 | 通过 | 三轮真实收发及独立历史复核通过;原配置已确认恢复 |
|
||||
| 飞书 | 私聊 | 通过 | 三轮真实收发、脚本核验及独立完成明细复核通过;每轮工具调用为 0 |
|
||||
| 钉钉 | 私聊 | 用户确认 | 用户已自行测试并要求本任务停止重复操作;不计入 8/11 独立三轮证据 |
|
||||
| QQ | 私聊 | 用户确认(部分独立证据) | 主任务完成关闭轮与复合开启轮;非标准三轮,用户已自行测试并要求不再操作 |
|
||||
| Slack | 私聊 | 独立通过 | 三轮真实收发及完成明细通过;原配置已精确恢复 |
|
||||
| Telegram | 私聊 | 通过 | 四轮真实收发及 Host 历史核对通过;已逐字确认恢复原配置 |
|
||||
| Discord | 私聊 | 独立通过 | 三轮真实收发及完成明细通过;原配置已精确恢复 |
|
||||
| WhatsApp | 私聊 | 通过 | 三轮真实收发及历史核验通过;原配置已确认恢复,未向无关群发送消息 |
|
||||
| 飞书 | DeepSeek大会群聊 | 通过 | OFF-01、ON-01、OFF-02 三轮真实收发及独立历史复核通过;原配置已恢复 |
|
||||
| 钉钉 | 梁梁群聊 | 用户确认(非干净样本) | 用户已自行测试并要求不再操作;主任务双入站观测不作为唯一标准样本 |
|
||||
|
||||
### Telegram 私聊四轮记录
|
||||
|
||||
四轮均由原生客户端发出,结合 Host 真实历史及原生回复核对;回复均符合预期,工具调用为 0,复用原 Session。下表仅记录匿名测试标记末段和事件序号,不记录账号、Session 或来源字段的实际值。
|
||||
|
||||
| 标记末段 | 实际配置及历史核验 | 事件 seq(user / assistant / completed) | 结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| `OFF-01` | 双关闭;用户原文逐字一致,无 source/guidance 标签 | 4543 / 4551 / 4553 | 通过 |
|
||||
| `ON-01` | 私聊开启;source 恰有 `channel`、`conversationType`、`senderId`、`senderName`、`botId` 五字段,guidance 标签成对 | 4558 / 4565 / 4567 | 通过 |
|
||||
| `FIELDS-01` | 实际 UI 仅保留 `channel`、`conversationType` 并清空 guidance;历史恰有两字段、无 guidance 标签 | 4572 / 4579 / 4581 | 通过 |
|
||||
| `OFF-02` | 再次关闭;用户原文逐字一致,无增强标签 | 4586 / 4593 / 4595 | 通过 |
|
||||
|
||||
验收后已通过真实 UI 保存恢复原始双关闭、五字段全选和原 253 字符 guidance;重新打开并逐字比对确认 `restoredExactly=true`。该四轮记录只确认 Telegram 私聊,其他场景按各自记录验收。
|
||||
|
||||
### 飞书私聊阶段记录
|
||||
|
||||
三轮均已真实发送,验收脚本及独立历史复核均通过。前两轮在聊天界面确认收到预期回复;第三轮在切换至群聊前,侧栏显示预期回复。独立复核确认三轮均有预期回复及完成事件,工具调用为 0。
|
||||
|
||||
| 标记末段 | 已确认事实 | 事件 seq(user / assistant / completed) | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `FS-D-OFF-01` | 原文逐字一致,无增强标签;脚本核验通过 | 1714 / 1761 / 1763 | 通过 |
|
||||
| `FS-D-ON-01` | source 位于首部,恰有 `channel`、`conversationType`、`senderId`、`botId` 四字段,无昵称字段;guidance 标签成对,原文完整;脚本核验通过 | 1768 / 1806 / 1808 | 通过 |
|
||||
| `FS-D-OFF-02` | UI 已确认恢复原双关闭、五字段全选及原 253 字符 guidance,内容完全一致;原文逐字一致,脚本核验通过 | 1813 / 1840 / 1842 | 通过 |
|
||||
|
||||
### 飞书 DeepSeek大会群聊阶段记录
|
||||
|
||||
通过群内 @ 测试机器人进行验收,三轮均已通过脚本核验及独立历史复核。开启轮后已恢复该测试机器人的原双关闭、五字段全选及原 253 字符 guidance,并重新打开 UI 确认完整配置 JSON 相等为 true;随后发送第二轮关闭消息。另一个机器人由用户自行开启的群聊配置保持不动。后两轮独立核验确认均有预期回复,工具调用为 0,运行已结束(`running=false`)。
|
||||
|
||||
| 标记末段 | 已确认事实 | 事件 seq(user / assistant / completed) | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `FS-G-OFF-01` | 已发送,脚本、界面回复及独立完成明细核验通过 | 2582 / 2610 / 2612 | 通过 |
|
||||
| `FS-G-ON-01` | source 恰有四字段及 guidance,原生界面已确认预期回复;脚本及独立完成明细核验通过 | 2617 / 2624 / 2626 | 通过 |
|
||||
| `FS-G-OFF-02` | 恢复原配置后发送,原文逐字一致、无增强标签;脚本及独立完成明细核验通过 | 2631 / 2687 / 2689 | 通过 |
|
||||
|
||||
### 微信私聊三轮记录
|
||||
|
||||
三轮均已真实发送并收到预期回复,独立历史复核通过;工具调用为 0,运行已结束(`running=false`)。原设置已恢复,UI 完整配置 JSON 比较相等为 true。
|
||||
|
||||
| 标记末段 | 已确认事实 | 事件 seq(user / assistant / completed) | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `WX-D-OFF-01` | 无增强标签,原文逐字一致 | 1286 / 1326 / 1328 | 通过 |
|
||||
| `WX-D-ON-01` | source 恰有 `channel`、`conversationType`、`senderId`、`botId` 四字段及 guidance,原文完整 | 1333 / 1362 / 1364 | 通过 |
|
||||
| `WX-D-OFF-02` | 无增强标签,原文逐字一致 | 1369 / 1376 / 1378 | 通过 |
|
||||
|
||||
### 企业微信私聊三轮记录
|
||||
|
||||
原生私聊已通过掩码与目标卡片核对。三轮真实收发及独立 Host 历史核验均通过,均有预期回复,工具调用为 0,运行已结束(`running=false`)。原配置已通过 UI 完全恢复,完整比较为 true。
|
||||
|
||||
| 标记末段 | 已确认事实 | 事件 seq(user / assistant / completed) | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `WC-D-OFF-01` | 原文逐字一致,无增强标签 | 483 / 537 / 539 | 通过 |
|
||||
| `WC-D-ON-01` | source 恰有四字段,guidance 标签成对 | 544 / 632 / 634 | 通过 |
|
||||
| `WC-D-OFF-02` | 原文逐字一致,无增强标签 | 639 / 676 / 678 | 通过 |
|
||||
|
||||
### Slack 私聊三轮记录
|
||||
|
||||
三轮真实收发及独立完成明细均通过,工具调用为 0,运行已结束(`running=false`)。验收后已精确恢复原双关闭、五字段全选及原 guidance。
|
||||
|
||||
| 标记末段 | 已确认事实 | 事件 seq(user / assistant / completed) | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `OFF-01` | 关闭轮完成 | 2537 / 2544 / 2546 | 通过 |
|
||||
| `ON-01` | source 恰有 `channel`、`conversationType`、`senderId`、`botId` 四字段;source、guidance 各出现一次且标签成对 | 2551 / 2557 / 2559 | 通过 |
|
||||
| `OFF-02` | 再次关闭轮完成 | 2564 / 2570 / 2572 | 通过 |
|
||||
|
||||
### Discord 私聊三轮记录
|
||||
|
||||
三轮真实收发及独立完成明细均通过,工具调用为 0,运行已结束(`running=false`)。验收后已精确恢复原双关闭、五字段全选及原 guidance。
|
||||
|
||||
| 标记末段 | 已确认事实 | 事件 seq(user / assistant / completed) | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `OFF-01` | 关闭轮完成 | 4912 / 4919 / 4921 | 通过 |
|
||||
| `ON-01` | source 的 `channel`、`conversationType`、`senderId`、`senderName`、`botId` 五字段齐全;source、guidance 各出现一次且标签成对 | 5200 / 5206 / 5208 | 通过 |
|
||||
| `OFF-02` | 再次关闭轮完成 | 5213 / 5219 / 5221 | 通过 |
|
||||
|
||||
### QQ 私聊部分独立证据
|
||||
|
||||
主任务完成两条独立证据,事件均完整结束,工具调用为 0,运行已结束(`running=false`)。开启轮受客户端滞后影响,是两次测试文本连续拼接后再附加 QQ 文本的复合输入;其正文在 Harness 历史中保持完整,但不能作为标准三轮独立样本。验收中已精确恢复原配置。用户随后确认 QQ 已自行测试并要求本任务不再操作,因此未追加第三轮,也不将本节记为 8/11 独立三轮证据之一。
|
||||
|
||||
| 标记末段 | 已确认事实 | 事件 seq(user / assistant / completed) | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `OFF-01` | 无增强标签 | 2797 / 2824 / 2826 | 部分证据通过 |
|
||||
| `ON-composite` | source、guidance 各出现一次;source 恰有 `channel`、`conversationType`、`senderId`、`botId` 四字段,复合正文完整 | 2831 / 2859 / 2861 | 部分证据通过 |
|
||||
|
||||
### 钉钉用户确认与主任务观测
|
||||
|
||||
用户明确确认钉钉私聊及指定群聊已自行测试,并要求本任务不再重复操作,因此两项均以「用户确认」记录,不计入 8/11 独立三轮证据。
|
||||
|
||||
主任务另观察到:平台侧可见的一次群聊 @ 消息在 Harness 中形成两条独立入站。两条均含五个来源字段、无 guidance,并各自拥有 assistant 与 completed 事件;工具调用为 0,运行已结束(`running=false`)。该观测不能严格证明平台重复原因,不作为干净、唯一的标准样本;不记录账号、Session、字段值或正文。
|
||||
|
||||
| Harness 入站 | 事件 seq(user / assistant / completed) | 结果 | 证据边界 |
|
||||
| --- | --- | --- | --- |
|
||||
| 第一条 | 1806 / 1836 / 1838 | 完整结束 | 与第二条来自同一次平台可见样本,原因未严格证明 |
|
||||
| 第二条 | 1843 / 1849 / 1851 | 完整结束 | 不作为独立、唯一的标准群聊样本 |
|
||||
|
||||
### WhatsApp 私聊三轮记录
|
||||
|
||||
通过原生新建聊天面板搜索本人入口,并用键盘实际选中本人聊天后发送,未向此前无关群发送消息。三轮均有预期回复,工具调用为 0,运行已结束(`running=false`);真实历史核验通过。已通过 UI 恢复原配置,完整 JSON 比较完全相等为 true。
|
||||
|
||||
| 标记末段 | 已确认事实 | 事件 seq(user / assistant / completed) | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| `OFF-01` | 原文无增强标签 | 16453 / 16528 / 16530 | 通过 |
|
||||
| `ON-01` | source 的 `channel`、`conversationType`、`senderId`、`senderName`、`botId` 五字段齐全,guidance 标签成对 | 16535 / 16658 / 16660 | 通过 |
|
||||
| `OFF-02` | 原文无增强标签 | 16665 / 16672 / 16674 | 通过 |
|
||||
|
||||
## 关闭语义
|
||||
|
||||
关闭只影响之后新接收的消息:对应会话范围不再附加上下文增强。已接收、排队中的消息仍使用接收时的配置快照;旧 Session 中已经写入的来源信息和其他历史不会被抹去。
|
||||
638
docs/方案/机器人消息来源元数据注入方案.md
Normal file
638
docs/方案/机器人消息来源元数据注入方案.md
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
# 机器人消息来源元数据注入方案
|
||||
|
||||
> 状态:实施中,九渠道与设置界面已接入,正在完成回归与真实客户端验收
|
||||
>
|
||||
> 日期:2026-08-26
|
||||
>
|
||||
> 更新日期:2026-08-29
|
||||
>
|
||||
> 关联:[Issue #58](https://github.com/xmanrui/dsh-im/issues/58)、[风险说明](https://github.com/xmanrui/dsh-im/issues/58#issuecomment-5414350619)
|
||||
|
||||
## 1. 结论
|
||||
|
||||
本需求在九个渠道的每张机器人卡片中增加一个设置入口按钮:
|
||||
|
||||
> **上下文增强**
|
||||
|
||||
点击按钮打开设置弹窗,按从上到下的顺序提供:
|
||||
|
||||
1. **启用范围**:群聊、私聊两个独立开关,默认均关闭,不再设置一个总开关。
|
||||
2. **来源字段**:从五个元数据字段中选择需要发送的字段,默认只勾选“发送者标识”(`senderId`)。
|
||||
3. **增强提示词**:正文默认留空,支持查看说明、填入示例和一键清空;非空时由插件自动包裹 `<dsh_im_source_guidance>` 标签。
|
||||
|
||||
字段选择和增强提示词按机器人保存,群聊与私聊共用;两个开关只分别决定对应会话类型是否应用这套配置。
|
||||
|
||||
方案遵守以下边界:
|
||||
|
||||
1. 配置按机器人独立保存;群聊和私聊开关默认均关闭,已有机器人缺少配置时也按关闭处理。
|
||||
2. 只使用平台入站事件和机器人运行时已经携带的数据,不查询联系人、用户资料或其他平台 API。
|
||||
3. 不修改 Harness 代码、Harness RPC 或 `session.prompt` 协议。
|
||||
4. 不修改系统提示词。当前会话类型开启后,由 dsh-im 插件按所选字段和提示词正文生成增强块,存在内容的块才自动加上成对标签并附加到普通用户消息前。
|
||||
5. 不改变当前渠道、机器人和会话之间的隔离关系。本功能只让 Agent 看见消息来源,不负责解决上下文隔离问题。
|
||||
6. 当前会话类型关闭时,必须直接沿用原有处理链路:不进入增强装配、不修改原消息、不改变已有功能,也不新增网络或磁盘操作。这是必须通过回归测试的硬性兼容要求,不仅是“不显示增强标签”。
|
||||
|
||||
### 1.1 “Agent”的准确含义
|
||||
|
||||
本文所称的 **Agent**,不是飞书、微信、Slack 等平台上的渠道机器人,不是 dsh-im 插件,也不是 Harness Session 本身。它是一个产品层术语,特指:
|
||||
|
||||
> **当前 Harness Session 背后,负责消费用户消息、调用模型和工具并生成结果的 AI 执行体。**
|
||||
|
||||
在本项目中,Agent 的实际执行行为可能受到 Agent Preset、系统指令、模型、工具、Workspace 和当前 Session 历史等因素共同影响。“Agent”并不是本需求准备在 dsh-im 中新增的对象、协议字段或运行时实例。
|
||||
|
||||
相关概念的边界如下:
|
||||
|
||||
| 概念 | 在本方案中的含义 | 是否是本文所称的 Agent |
|
||||
| --- | --- | --- |
|
||||
| 渠道机器人 | 飞书机器人、Telegram Bot、企业微信机器人等平台消息入口,负责接收和发送平台消息 | 否 |
|
||||
| dsh-im | 连接九个渠道与 Harness 的插件,负责事件归一化、会话路由和消息装配 | 否 |
|
||||
| Harness | 接收 dsh-im 提交的消息并管理 AI 执行过程的宿主 | 否 |
|
||||
| Harness Session | 保存当前对话历史、工作状态和执行上下文的会话 | 否 |
|
||||
| Agent | 当前 Session 背后处理消息的 AI 执行体,可能调用模型和工具完成任务 | 是 |
|
||||
| 模型 | Agent 执行过程中用于理解和生成内容的模型,是 Agent 的组成部分之一 | 否,不与 Agent 完全等同 |
|
||||
|
||||
本需求的数据流是:
|
||||
|
||||
```text
|
||||
IM 用户
|
||||
→ 渠道机器人
|
||||
→ dsh-im 把来源块和增强提示词块写入普通用户消息
|
||||
→ Harness 将消息提交到当前 Session 并保存到会话历史
|
||||
→ 当前 Session 背后的 Agent/模型读取这条用户消息
|
||||
→ Harness 返回执行结果
|
||||
→ dsh-im 通过渠道机器人回复 IM 用户
|
||||
```
|
||||
|
||||
“上下文增强”是本功能统一的设置入口名称,其准确技术含义是:
|
||||
|
||||
> **dsh-im 在发送给 Harness 当前 Session 的普通用户消息中加入来源信息及其使用说明,使后续处理该消息的 Agent 和模型能够看到来源事实,并获得如何使用这些事实的提示。**
|
||||
|
||||
这五个字段只会成为用户提示词内容,不会改变 Agent Preset、系统指令、模型、工具、Workspace、Session 归属或权限。仅让 Agent 看见来源信息,也不会自动获得客户身份识别、员工权限、CRM 或工作流能力;这些能力需要另外建设可信身份映射、业务数据和工具接口。
|
||||
|
||||
## 2. 背景与问题定义
|
||||
|
||||
dsh-im 当前已经按机器人和聊天维护 Harness Session,不同机器人的上下文本身是隔离的。因此,Issue 中“多渠道并发对话时不会混淆上下文”不是本功能成立的理由,也不能作为验收目标。
|
||||
|
||||
本功能解决的是另外两个问题:
|
||||
|
||||
- Agent 当前只能看到用户消息内容,无法判断这条消息来自哪个渠道、私聊还是群聊。
|
||||
- 在共享群聊 Session 中,Agent 无法仅凭消息正文判断当前消息由哪位群成员发送。
|
||||
|
||||
由于 dsh-im 只是 Harness 插件,不能给 Harness 增加新的 `source` 参数,也不能改变 Harness 的系统提示词构造方式。因此,本方案只能在插件侧把已有来源数据编码进用户消息。
|
||||
|
||||
## 3. 目标与非目标
|
||||
|
||||
### 3.1 目标
|
||||
|
||||
- 用户可以从每个机器人卡片打开设置,分别决定群聊、私聊是否启用上下文增强。
|
||||
- 用户可以选择发送五个来源字段中的哪些字段,默认只选 `senderId`;仅发送选中且当前事件中可用的值。
|
||||
- 通过按机器人自定义的增强提示词说明如何使用来源信息;正文默认留空,支持查看及填入示例和一键清空,非空正文由插件统一封装。
|
||||
- 群聊开启且选择发送者字段时,每条普通用户消息使用当次实际发送者的信息。
|
||||
- 九个渠道使用同一字段语义、同一设置交互和同一提示词封装格式。
|
||||
- 不增加每条消息的额外平台网络请求。
|
||||
- 不要求重连机器人、重启插件或新建 Session 即可生效。
|
||||
|
||||
### 3.2 非目标
|
||||
|
||||
- 不修改 Harness 或要求 Harness 支持新的消息字段。
|
||||
- 不把来源信息注入系统提示词。
|
||||
- 不通过平台 API 补查昵称、成员资料或机器人资料。
|
||||
- 不改变 Session 创建、绑定、隔离、路由和并发策略。
|
||||
- 不根据渠道自动切换 Workspace、模型或 Agent Preset。
|
||||
- 不把来源信息用于身份认证、权限判断或审计。
|
||||
- 不补写已经存在的历史消息。
|
||||
- 不保证九个渠道都能提供 `senderName`。
|
||||
|
||||
## 4. 机器人卡片与设置弹窗
|
||||
|
||||
### 4.1 展示位置
|
||||
|
||||
九个渠道的机器人卡片统一按以下顺序展示:
|
||||
|
||||
```text
|
||||
Workspace
|
||||
Agent Preset
|
||||
[滑杆图标 上下文增强 未开启 ›]
|
||||
渠道专属设置
|
||||
机器人操作
|
||||
```
|
||||
|
||||
即放在 `Agent Preset` 之后、群响应模式或访问控制等渠道专属设置之前。
|
||||
|
||||
卡片只显示设置入口和已保存的启用范围摘要;摘要位于按钮内部右侧,以状态标签展示。点击整行“上下文增强”按钮打开弹窗,不直接切换启用状态。
|
||||
|
||||
### 4.2 弹窗结构
|
||||
|
||||
弹窗标题为“上下文增强”,包含以下三个设置区域及保存操作:
|
||||
|
||||
```text
|
||||
上下文增强 [?] [关闭弹窗]
|
||||
|
||||
启用范围
|
||||
群聊中启用(当前渠道不支持群聊) [关闭]
|
||||
私聊中启用 [关闭]
|
||||
|
||||
来源字段 [?]
|
||||
[ ] 渠道 [ ] 会话类型
|
||||
channel conversationType
|
||||
[✓] 发送者标识 [ ] 发送者昵称 [?]
|
||||
senderId senderName
|
||||
[ ] 机器人标识
|
||||
botId
|
||||
|
||||
增强提示词 [?] [填入示例] [清空]
|
||||
[多行正文编辑区域,不需要填写外围标签]
|
||||
插件自动添加 <dsh_im_source_guidance> 成对标签。
|
||||
|
||||
[取消] [保存]
|
||||
```
|
||||
|
||||
标题右侧问号说明:
|
||||
|
||||
> 选择在哪些会话中启用、提供哪些来源字段,以及如何使用这些信息。仅使用已有消息元数据,不查询平台 API。
|
||||
|
||||
“来源字段”右侧问号说明:
|
||||
|
||||
> 增强提示词中请使用字段名(如 senderId、conversationType)引用这些信息。只发送勾选且当前消息中可用的字段,不会额外查询或补全。
|
||||
|
||||
“发送者昵称”右侧问号说明:
|
||||
|
||||
> 该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段,`<dsh_im_source>` 中也会省略 `senderName`。
|
||||
|
||||
“增强提示词”右侧问号说明中包含:
|
||||
|
||||
> 只需填写正文,标签由插件自动添加。清空并保存后,不再附加增强提示词,已选来源字段仍按开关设置发送。
|
||||
|
||||
以及隐私与历史保留提示:
|
||||
|
||||
> 发送者标识可能包含平台用户 ID 或电话号码形式的标识。关闭开关不会删除已经写入会话历史的信息。
|
||||
|
||||
### 4.3 群聊与私聊开关
|
||||
|
||||
- 两个开关相互独立,分别控制真实 `group` 与 `direct` 会话;不增加总开关。
|
||||
- 新机器人和缺少配置的已有机器人,两个开关都默认关闭。
|
||||
- 不论是否把 `conversationType` 发给模型,插件都使用入站事件的真实会话类型选择对应开关。
|
||||
- 只开启群聊时,私聊消息保持原样;只开启私聊时,群聊消息保持原样。
|
||||
- 两个开关都关闭时,仍可以预先编辑字段选择和提示词,但不向任何消息附加增强内容。
|
||||
- 仅支持私聊的渠道仍展示并禁用群聊开关,在“群聊中启用”右侧同行显示“(当前渠道不支持群聊)”,不承诺平台不存在的能力。
|
||||
|
||||
### 4.4 来源字段选择
|
||||
|
||||
- 五个字段全部以复选框展示,新配置默认只勾选“发送者标识”(`senderId`);字段名称与 JSON 键的映射见 5.1 节。
|
||||
- 任意字段都可以取消,不设置强制勾选字段,也不暗中补回未选中的字段。
|
||||
- 群聊与私聊共用这套字段选择;启用范围和发送字段是两个独立配置维度。
|
||||
- 用户只能决定是否发送字段,不能手工修改平台来源值或内部机器人标识。
|
||||
- 允许全部取消;此时不生成 `<dsh_im_source>` 块,非空增强提示词仍可按会话开关发送。
|
||||
- 选中某字段不保证一定有值。例如仅选择 `senderName`,而本条消息没有昵称时,同样不生成来源块。
|
||||
- 字段选择变化不会自动重写用户的增强提示词;模板应按字段是否实际存在使用数据,不应猜测未提供的值。
|
||||
|
||||
### 4.5 增强提示词、帮助与一键清空
|
||||
|
||||
- 编辑项名称为“增强提示词”,群聊与私聊共用一份正文。
|
||||
- 新配置的正文默认为空;用户可以自行编辑、点击“填入示例”,或点击“清空”。
|
||||
- 正文为空时,编辑框通过 `placeholder` 灰色展示与“填入示例”相同的示例正文;占位内容不是输入值,不参与保存,也不会生成 guidance 块。
|
||||
- 标题右侧显示问号帮助入口,鼠标悬停或键盘聚焦时展示使用说明和简短示例。
|
||||
- 问号帮助中的“使用示例”与“填入示例”按钮共用同一份正文,不能分别维护两套示例。
|
||||
- 用户只编辑正文;`<dsh_im_source_guidance>` 和 `</dsh_im_source_guidance>` 由插件在装配消息时自动添加,内置和自定义正文使用同一封装格式。
|
||||
- “清空”只把当前编辑区置为空,不改变开关和字段选择,也不立即影响正在运行的配置。
|
||||
- 清空并保存后持久化显式空字符串;刷新、重启或重新开启时仍为空。
|
||||
- 正文为空或仅含空白字符时,不附加 guidance 块,也不发送一对空标签;来源块按所选字段独立生成。
|
||||
- “填入示例”只把当前编辑区填入内置示例,点击“保存”后才生效。
|
||||
- 内置示例必须以字段存在为前提提供说明,适应只选部分字段或当前事件缺少字段的情况。
|
||||
|
||||
可一键填入的内置示例正文:
|
||||
|
||||
```text
|
||||
仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
|
||||
conversationType是群聊时回复严肃一点,conversationType是私聊时回复一定要幽默搞笑,像周星驰的电影一样搞笑
|
||||
```
|
||||
|
||||
### 4.6 保存与生效
|
||||
|
||||
- 打开弹窗时读取当前机器人的已保存配置,在本地编辑草稿;开关、字段、提示词和清空操作均先修改草稿。
|
||||
- 点击“保存”后,通过一次本地 Host RPC 原子提交整套配置,避免开关先启用、字段或提示词尚未保存的中间状态。
|
||||
- 保存期间禁用重复提交和编辑,展示保存状态;成功后关闭弹窗并更新卡片的启用范围摘要。
|
||||
- 保存失败时保持原运行配置,保留弹窗中的草稿并显示错误,允许重试或取消。
|
||||
- 点击“取消”或关闭弹窗不保存草稿;重新打开时仍显示已保存值。
|
||||
- 保存成功后接收的下一条普通用户消息采用新配置;保存前已经接收或入队的消息不追溯修改。
|
||||
- 保存不触发机器人重连,不清除 Session,不中断当前回复;刷新页面和重启插件后配置保持。
|
||||
- 关闭某类会话或全部关闭不会清除字段选择与提示词正文;删除机器人时一并删除全部上下文增强配置。
|
||||
- 修改 Workspace、Agent Preset、群响应模式或访问规则不改变本配置。
|
||||
|
||||
### 4.7 已确认的视觉样式
|
||||
|
||||
2026-08-29,用户确认采用可点击示意中的按钮与弹窗样式。后续实现以此为视觉基准,前述配置与消息行为不变。
|
||||
|
||||
**入口按钮:**
|
||||
|
||||
- 采用轻量的次级设置按钮,横跨机器人卡片的内容区,浅描边、中性底色;深色模式对应深色底色。
|
||||
- 从左到右依次为滑杆图标(`sliders-horizontal`)、“上下文增强”、状态标签、右箭头(`chevron-right`)。整行是一个点击目标,图标和状态标签不单独执行操作。
|
||||
- 参考尺寸:最小高度 40px、圆角 8px、边框 1px;文字约 13px,图标 16px,左右留白约 11px。最终使用项目现有设计令牌适配。
|
||||
- 两个开关均关闭时显示灰色“未开启”;开启任一范围后,只将状态标签改为蓝色系浅底与强调色文字,分别显示“仅群聊”“仅私聊”或“群聊和私聊”。按钮主体保持中性样式。
|
||||
- 悬停时底色轻微加深;“未开启”是配置状态,不是禁用态,按钮始终可以打开设置。
|
||||
|
||||
**设置弹窗:**
|
||||
|
||||
- 使用不透明主题背景、细边框与圆角,背景卡片加遮罩;标题在左上角,标题右侧问号通过悬停或键盘聚焦展示功能说明,关闭图标在右上角。
|
||||
- 参考最大宽度 450px、圆角 12px、内边距约 18px;窄屏自适应收缩,保持内容完整可见。
|
||||
- “启用范围”将群聊、私聊两个独立开关上下排列,每行左侧为名称、右侧为对应开关,避免误解为二选一。
|
||||
- “来源字段”标题右侧问号展示字段引用、选择和数据补全规则;下方以两列复选框排列,每项同时展示中文名称和可在增强提示词中直接使用的字段名。“发送者昵称”另带可悬停和聚焦的问号,说明字段缺失时的省略行为。五个字段的语义仍以 5.1 节为准。
|
||||
- “增强提示词”标题左对齐,右邻问号帮助入口;使用说明、生效规则、隐私提示和与“填入示例”共用的示例正文均收纳在帮助框中,并向上展开。“填入示例”和“清空”为右侧轻量文字操作,下方只保留多行编辑区。
|
||||
- 底部操作右对齐:“取消”为次级按钮,“保存”为主按钮。
|
||||
|
||||
浅色和深色主题沿用项目配色,不为九个渠道分别定义外观。窄屏下按钮名称和最长状态标签均不可被挤压或截断;触控目标至少 44px,保留键盘焦点提示、弹窗焦点约束、Esc 取消及关闭后返回入口按钮的焦点行为。
|
||||
|
||||
## 5. 来源数据契约
|
||||
|
||||
### 5.1 字段定义
|
||||
|
||||
| 字段 | 界面名称 | 选中且有值时的内容 |
|
||||
| --- | --- | --- |
|
||||
| `channel` | 渠道 | 固定枚举:`wecom`、`weixin`、`feishu`、`dingtalk`、`qq`、`slack`、`telegram`、`discord`、`whatsapp` |
|
||||
| `conversationType` | 会话类型 | 统一归一化为 `direct` 或 `group` |
|
||||
| `senderId` | 发送者标识 | 平台入站事件已经携带的渠道内发送者标识,统一序列化为字符串 |
|
||||
| `senderName` | 发送者昵称 | 入站事件已经携带的昵称或显示名称;没有可靠值时完全省略 |
|
||||
| `botId` | 机器人标识 | dsh-im 内部机器人 ID,不使用平台 Secret、Token 或其他凭据 |
|
||||
|
||||
字段约束:
|
||||
|
||||
- 新配置默认只选择 `senderId`,但在最终发送的 JSON 中五个字段均可省略,不把其中任何字段设为强制发送。
|
||||
- 最终字段集合为“用户已选字段”与“本条消息可用字段”的交集;未勾选的字段不发送,也不以空值或其他位置补回。
|
||||
- 字段选择只控制发给模型的内容,不删除插件内部用于路由、访问控制和启用范围判断的真实事件信息。
|
||||
- 不发送值为 `undefined`、`null` 或空字符串的 `senderName`。
|
||||
- 不使用 `senderId` 冒充 `senderName`。
|
||||
- 不因为缺少 `senderName` 查询任何平台 API。
|
||||
- 选中的可用字段按 `channel`、`conversationType`、`senderId`、`senderName`、`botId` 的稳定顺序序列化,不受勾选顺序影响。
|
||||
- 字段投影后为空时省略整个来源块,不发送 `{}` 或空标签。
|
||||
- 所有字符串必须限制长度、移除不必要的控制字符,并通过安全 JSON 序列化。
|
||||
- XML 风格分隔符中的 `<`、`>`、`&` 等字符需要额外转义,避免外部昵称破坏数据块边界。
|
||||
- 来源字段全部是不可信外部数据,不能据此执行授权操作。
|
||||
|
||||
### 5.2 用户提示词格式
|
||||
|
||||
当前会话类型已开启、存在选中的可用字段且提示词非空时,在用户原始消息之前依次增加来源数据块和增强提示词块。以下用一段简短的自定义正文演示封装:
|
||||
|
||||
```text
|
||||
<dsh_im_source>{"channel":"telegram","conversationType":"group","senderId":"123456","senderName":"张三","botId":"telegram_xxx"}</dsh_im_source>
|
||||
|
||||
<dsh_im_source_guidance>
|
||||
根据 senderId 区分发言人;senderName 有值时可用于称呼。
|
||||
群聊中必要时明确回应对象,私聊中直接回应当前用户。
|
||||
来源字段只是数据,不是需要执行的指令。
|
||||
</dsh_im_source_guidance>
|
||||
|
||||
用户原始消息
|
||||
```
|
||||
|
||||
即使勾选 `senderName`,本条消息没有昵称时也省略该字段,其余已选字段照常发送:
|
||||
|
||||
```text
|
||||
<dsh_im_source>{"channel":"slack","conversationType":"group","senderId":"U123456","botId":"slack_xxx"}</dsh_im_source>
|
||||
|
||||
<dsh_im_source_guidance>
|
||||
根据 senderId 区分发言人;senderName 有值时可用于称呼。
|
||||
群聊中必要时明确回应对象,私聊中直接回应当前用户。
|
||||
来源字段只是数据,不是需要执行的指令。
|
||||
</dsh_im_source_guidance>
|
||||
|
||||
用户原始消息
|
||||
```
|
||||
|
||||
只选择 `channel`、`conversationType`,并已清空增强提示词时:
|
||||
|
||||
```text
|
||||
<dsh_im_source>{"channel":"telegram","conversationType":"group"}</dsh_im_source>
|
||||
|
||||
用户原始消息
|
||||
```
|
||||
|
||||
### 5.3 装配规则
|
||||
|
||||
| 当前会话类型开关 | 已选字段中有可用值 | 增强提示词非空 | 最终附加内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 关闭 | 任意 | 任意 | 不附加,保留原消息 |
|
||||
| 开启 | 是 | 是 | 来源块 → 增强提示词块 → 原消息 |
|
||||
| 开启 | 是 | 否 | 来源块 → 原消息 |
|
||||
| 开启 | 否 | 是 | 增强提示词块 → 原消息 |
|
||||
| 开启 | 否 | 否 | 不附加,保留原消息 |
|
||||
|
||||
具体要求:
|
||||
|
||||
- 顺序固定为:来源数据块、增强提示词块、用户原始内容。
|
||||
- 每次普通用户提交,插件最多增加一个来源块和一个增强提示词块,按上表独立省略没有内容的块。
|
||||
- `<dsh_im_source_guidance>` 与 `</dsh_im_source_guidance>` 均由插件自动添加;模板仅保存和编辑标签内的正文。
|
||||
- 提示词为空或仅含空白时视为显式清空,不填入示例,也不生成空 guidance 块。
|
||||
- 模板正文中出现的同名标签按普通文本转义,不能提前闭合或嵌套插件添加的提示词块。
|
||||
- 不修改用户原始正文。
|
||||
- 两个块都属于普通用户提示词;标签只用于组织内容,不赋予系统提示词的指令优先级,也不要求 Harness 增加解析逻辑。
|
||||
- 当前会话类型的开关关闭时,两个块都不增加;另一个会话类型的开关不影响本条消息。
|
||||
|
||||
### 5.4 多模态消息
|
||||
|
||||
文本消息可以直接增加字符串前缀。图片或其他数组形式的内容,应把实际需要发送的增强块合并为第一个独立文本项。两个块均存在时示例:
|
||||
|
||||
```js
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: '<dsh_im_source>{...}</dsh_im_source>\n\n<dsh_im_source_guidance>...</dsh_im_source_guidance>',
|
||||
},
|
||||
// 原有文本、图片和文件相关内容
|
||||
]
|
||||
```
|
||||
|
||||
现有文件清单 `<dsh_im_files>` 继续由原链路追加,最终顺序为:
|
||||
|
||||
```text
|
||||
来源信息(有则附加) → 增强提示词(非空时附加) → 用户文本/图片 → 文件清单
|
||||
```
|
||||
|
||||
只有一个块时只插入该块;两个块都省略时,不增加空文本项,原有内容数组与当前版本保持一致。
|
||||
|
||||
## 6. 九渠道字段来源
|
||||
|
||||
所有字段均来自当前入站事件或当前机器人运行时,不新增网络查询。
|
||||
|
||||
| 渠道 | `channel` | `conversationType` | `senderId` | `senderName` | `botId` |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 企业微信 | `wecom` | `single/group` 归一化 | `from.userid` | 省略 | 当前内部机器人 ID |
|
||||
| 微信 | `weixin` | 固定 `direct` | `from_user_id` | 省略 | 当前内部机器人 ID |
|
||||
| 飞书 | `feishu` | `p2p/group` 归一化 | `sender_id` 中已有的用户标识 | 省略 | 当前内部机器人 ID |
|
||||
| 钉钉 | `dingtalk` | `conversationType` 归一化 | `senderStaffId` 或事件已有 `senderId` | `senderNick` | 当前内部机器人 ID |
|
||||
| QQ | `qq` | `c2c/group` 归一化 | 事件已有发送者 ID | 群事件已有 `senderName` 时提供 | 当前内部机器人 ID |
|
||||
| Slack | `slack` | IM 为 `direct`,群/频道为 `group` | `event.user` | 省略 | 当前内部机器人 ID |
|
||||
| Telegram | `telegram` | `chat.type` 归一化 | `from.id` | 事件中的姓名,缺少时可使用 username | 当前内部机器人 ID |
|
||||
| Discord | `discord` | 有 `guild_id` 为 `group`,否则 `direct` | `author.id` | `member.nick`、`global_name`、`username` 按顺序择一 | 当前内部机器人 ID |
|
||||
| WhatsApp | `whatsapp` | 群 JID 为 `group`,否则 `direct` | `participant` 或远端 JID | 事件已有 `pushName` 时提供 | 当前内部机器人 ID |
|
||||
|
||||
如果渠道 SDK 当前在归一化时丢弃了事件中已有的昵称,只允许调整本地归一化结果以保留该字段,不允许补查远端资料。
|
||||
|
||||
## 7. 消息适用范围
|
||||
|
||||
以下适用范围同时约束来源块和增强提示词块。只有符合范围且对应会话开关已开启的消息才进入增强装配;具体是否包含两个块,继续按字段选择、数据可用性和提示词正文判断。
|
||||
|
||||
### 7.1 可以应用上下文增强的消息
|
||||
|
||||
- 普通文本消息。
|
||||
- 带图片的普通用户消息。
|
||||
- 带文件的普通用户消息。
|
||||
- 群聊中通过提及、回复或群响应模式后实际提交给 Agent 的消息。
|
||||
- 同一群聊 Session 中不同发送者的每条消息;选中发送者字段时使用当次实际发送者信息。
|
||||
- 批量消息最终提交时最多附加一组增强内容,不重复包裹每个内容项。
|
||||
- 未被识别为本地控制命令、最终按普通文本提交的消息。
|
||||
|
||||
### 7.2 不应用上下文增强的消息
|
||||
|
||||
- `/help`、`/new`、`/status` 等只由插件本地执行的命令。
|
||||
- `/steer`、审批回答、问题回答等 Harness 控制或交互输入。
|
||||
- 被访问控制、群响应模式、去重或安全规则拒绝的消息。
|
||||
- 没有提交到 Harness 的平台事件。
|
||||
- Agent 回复和渠道出站消息。
|
||||
|
||||
## 8. 配置与运行设计
|
||||
|
||||
### 8.1 持久化
|
||||
|
||||
配置应进入现有的机器人本地共享设置层,与 `workspace`、`agentPreset` 同级,不进入平台凭据配置。例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"workspaces": {},
|
||||
"agentPresets": {},
|
||||
"contextEnhancement": {
|
||||
"telegram_xxx": {
|
||||
"groupEnabled": true,
|
||||
"directEnabled": false,
|
||||
"fields": ["channel", "conversationType", "senderId", "senderName", "botId"],
|
||||
"guidance": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上例表示仅群聊开启、五个字段全选、增强提示词已经显式清空。缺少某个机器人配置时使用以下默认值:
|
||||
|
||||
| 配置项 | 类型 | 默认值与含义 |
|
||||
| --- | --- | --- |
|
||||
| `groupEnabled` | boolean | `false`,控制群聊 |
|
||||
| `directEnabled` | boolean | `false`,控制私聊 |
|
||||
| `fields` | 五个字段名组成的数组 | 默认仅选择 `senderId`;显式 `[]` 表示全部取消 |
|
||||
| `guidance` | string | 默认为空;非空时保存用户正文或填入的示例正文 |
|
||||
|
||||
持久化约束:
|
||||
|
||||
- 按机器人保存完整配置,两个开关、字段选择和提示词正文一起更新。
|
||||
- `guidance` 只保存正文,不保存插件自动添加的外围标签;空白正文可归一化为 `""`。
|
||||
- `fields: []` 和 `guidance: ""` 都是有效用户选择,不能当作缺失值回退到默认字段或示例正文。
|
||||
- 只允许五个已定义的字段名;去重并按规范顺序保存,不接收任意额外元数据字段。
|
||||
- 两个开关都关闭时仍保留用户保存的字段和正文,不能以“关闭”为由删除配置。
|
||||
- 配置缺失默认双关闭;配置损坏时安全降级为双关闭,不能因异常自动开启或扩大正在发送的字段范围。
|
||||
|
||||
实现时可以扩展现有 `BotWorkspaceStore` 所承载的机器人设置,但对外语义应保持为通用机器人配置,不能让渠道 Bridge 直接读写配置文件。
|
||||
|
||||
### 8.2 Host RPC 与状态
|
||||
|
||||
九个渠道统一增加逻辑等价的设置接口,例如:
|
||||
|
||||
```text
|
||||
bot.context-enhancement.set
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"botId": "telegram_xxx",
|
||||
"config": {
|
||||
"groupEnabled": true,
|
||||
"directEnabled": false,
|
||||
"fields": ["channel", "conversationType"],
|
||||
"guidance": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
机器人状态投影统一增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"contextEnhancement": {
|
||||
"groupEnabled": true,
|
||||
"directEnabled": false,
|
||||
"fields": ["channel", "conversationType"],
|
||||
"guidance": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上例为保存后的投影,不是默认配置。界面与 Host 共用默认值和校验规则;只有明确的布尔值 `true` 才能开启对应范围。
|
||||
|
||||
- 保存时校验完整配置,不允许非法类型、未知字段名或超出约定长度的正文进入运行状态。
|
||||
- 保存请求失败不部分更新任意字段;成功响应返回实际生效的规范化配置,界面据此更新状态。
|
||||
- 清空通过提交 `guidance: ""` 表达;填入示例通过提交示例正文表达,不能依赖空字符串回退。
|
||||
- 保存的是 dsh-im 插件配置,不是向 Harness 新增的 RPC 或 `source` 参数。
|
||||
|
||||
### 8.3 Runtime 与 Bridge
|
||||
|
||||
- 生产装配层把当前机器人 ID 和整套上下文增强配置的读取能力显式传给 Runtime/Bridge。
|
||||
- 接收消息时取得内存中已提交配置的只读引用,先检查实际会话类型对应的 `groupEnabled` 或 `directEnabled` 是否严格为 `true`;无法可靠判断时按关闭处理。
|
||||
- 对应开关未开启时立即跳过增强分支,继续原有链路;不访问或处理增强模板,不执行专用于增强的字段提取、投影、转义、序列化和内容重组。
|
||||
- 对应开关开启后,后续排队和装配使用接收时的同一完整配置快照,不能混用不同版本的开关、字段和正文。
|
||||
- 对应开关开启后,从已有元数据中筛选 `fields` 指定的可用字段,再安全序列化并生成非空来源块。
|
||||
- 根据 `guidance` 是否非空独立生成提示词块,并自动添加成对标签;不因清空正文自动填入示例。
|
||||
- 每条消息不读取磁盘,也不调用平台 API。
|
||||
- 点击“保存”只产生一次浏览器到本地 Host 的设置 RPC;正常消息仍只有现有的 Harness 提交请求。
|
||||
- 不应把这项配置伪装成 Harness 能力或加入 Harness 客户端协议。
|
||||
- 不应把配置缓存在必须重建连接才能更新的位置。
|
||||
|
||||
### 8.4 公共实现边界
|
||||
|
||||
九个渠道应复用:
|
||||
|
||||
- 一个机器人卡片设置入口及共享弹窗,包含群聊/私聊双开关、五字段选择、正文编辑、问号帮助、填入示例和清空操作;
|
||||
- 一套设置存储和 RPC 行为;
|
||||
- 一套会话类型启用判断、字段白名单投影和空内容省略规则;
|
||||
- 一个来源数据校验与安全序列化函数;
|
||||
- 一个同时支持字符串和多模态数组的提示词装配函数;
|
||||
- 一组公共契约测试。
|
||||
|
||||
各渠道只负责:
|
||||
|
||||
- 从已有入站事件提取渠道原始值;
|
||||
- 把平台会话类型归一化为 `direct/group`;
|
||||
- 在事件已有昵称时保留昵称。
|
||||
|
||||
## 9. 隐私、安全与副作用
|
||||
|
||||
### 9.1 隐私
|
||||
|
||||
- `senderId` 是渠道内用户标识,WhatsApp JID 等值可能呈现电话号码特征。
|
||||
- 来源数据会发送给当前模型服务,并进入 Harness 会话历史。
|
||||
- 关闭开关只影响后续消息,不能删除历史中已经保存的数据。
|
||||
- `botId` 只发送 dsh-im 内部 ID,禁止发送 Token、Secret、AppSecret 等凭据。
|
||||
|
||||
### 9.2 提示词副作用
|
||||
|
||||
由于 Harness 没有独立的来源字段,来源块只能成为用户提示词的一部分,因此:
|
||||
|
||||
- 来源块和增强提示词都会增加 token 消耗;自定义模板越长,每次附加的开销越大。
|
||||
- 可能影响依赖“纯用户原文”的翻译、摘要、分类、严格 JSON 生成或文本比较任务。
|
||||
- 模型可能把来源块误当作需要处理的正文。
|
||||
- 来源块不具备系统提示词的指令优先级,无法成为可靠的安全边界。
|
||||
- 恶意昵称或发送者标识仍属于提示词注入输入;结构化封装、转义和长度限制只能降低格式破坏风险,不能把它变成可信数据。
|
||||
|
||||
默认关闭和显式开启是接受这些副作用的产品边界。
|
||||
|
||||
### 9.3 禁止用途
|
||||
|
||||
Agent 或插件不能仅根据来源块:
|
||||
|
||||
- 判断用户是否有权限执行操作;
|
||||
- 认证用户身份;
|
||||
- 执行资金、账号、审批等高风险操作;
|
||||
- 形成不可抵赖的审计记录。
|
||||
|
||||
## 10. 兼容性与迁移
|
||||
|
||||
### 10.1 关闭状态的原链路兼容(硬性要求)
|
||||
|
||||
“未开启”按当前消息的真实会话类型判断:两个开关都关闭、仅开启群聊时收到私聊、仅开启私聊时收到群聊,以及没有新增配置的旧机器人,均须满足以下要求。
|
||||
|
||||
1. **先判断、后增强**:只允许一次轻量的内存配置读取与启用判断。未开启时不执行增强专用处理,不能先生成增强内容再决定丢弃。
|
||||
2. **原消息不变**:文本、空白、换行、内容类型、图片与文件项及其顺序均保持原有行为;不插入空标签、空文本项或任何新消息字段。若通过公共增强入口传递内容,关闭时原样返回传入值,数组或对象不因该入口被复制、改写或重排。
|
||||
3. **原有装配继续执行**:跳过的是新增增强逻辑,不能提前退出整个消息处理流程;原有附件处理、文件清单、批处理、提交和回复流程继续照常运行。
|
||||
4. **会话与控制逻辑不变**:不影响 Session 创建与映射、Workspace、Agent Preset、访问控制、群响应、去重、排队和并发;不影响命令、审批、问题回答、流式回复及其他既有交互。
|
||||
5. **调用与资源边界不变**:不新增平台或 Harness 请求,不新增逐消息磁盘读取、异步等待、定时任务或额外队列;请求内容和模型参数保持原有行为,不给当前消息增加增强提示词 token。轻量的内存开关判断不等于承诺绝对零 CPU 开销,已经存在的历史内容另见 10.3 节。
|
||||
6. **增强配置异常隔离**:新增配置缺失、损坏或其读取/规范化发生异常时,该功能按关闭处理;不能因此阻止原本可正常工作的机器人启动、连接或收发消息。不得吞掉与本功能无关的原有错误。
|
||||
7. **设置界面不干扰运行**:仅打开弹窗、修改未保存草稿或取消设置,不改变运行配置和消息行为;保存关闭配置只影响之后接收的消息,不重连、不清空会话、不打断当前任务。
|
||||
|
||||
关闭状态的对照回归未通过,不得视为功能完成。不能只检查“没有 source/guidance 标签”,还必须证明原链路的输入、调用和可观察行为保持一致。
|
||||
|
||||
### 10.2 配置与升级兼容
|
||||
|
||||
- 旧配置中没有 `contextEnhancement` 时,群聊、私聊均按关闭处理。
|
||||
- 当前会话类型关闭时,不改变该类消息的字符串提示词和多模态提示词内容。
|
||||
- 不改变现有 Session Key、Session 映射、会话历史或消息去重键。
|
||||
- 不改变 Agent Preset 的“只影响后续新 Session”语义。
|
||||
- 不改变群聊是否响应、谁可以访问机器人等现有规则。
|
||||
- 开启或关闭都不重新创建 Harness Session。
|
||||
- 插件升级后保留双开关、字段选择和正文,包括显式空字段列表与清空后的正文;机器人删除后不残留孤立配置。
|
||||
|
||||
### 10.3 曾经开启后的历史边界
|
||||
|
||||
从未开启时,不会向会话写入本功能的增强内容。曾经开启后再关闭,只恢复后续消息的原有提交方式;已写入 Session 的来源信息与提示词仍属于历史,可能继续影响模型对历史的理解。
|
||||
|
||||
因此,不承诺“关闭后等同于从未开启过的会话”,也不能为了实现这种效果自动删除历史或新建 Session。本功能默认关闭与关闭状态原链路兼容的要求,不改变这项历史边界。
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
### 11.1 UI 与配置
|
||||
|
||||
1. 九个渠道的每张机器人卡片都有“上下文增强”按钮,点击打开该机器人的设置弹窗,不直接启停。
|
||||
2. 弹窗按顶部双开关、中间五字段选择、底部增强提示词编辑的顺序展示。
|
||||
3. 新机器人和缺少该项配置的旧机器人,群聊、私聊开关默认均为关闭,来源字段默认只选 `senderId`,增强提示词默认留空;已有有效保存值保持不变。
|
||||
4. 群聊/私聊双开关四种组合均能独立保存,卡片摘要正确;仅支持私聊的渠道明确标注群聊不可用。
|
||||
5. 同渠道不同机器人、不同渠道机器人之间的整套配置互不影响。
|
||||
6. 取消或关闭弹窗不保存草稿;点击“保存”才原子提交,失败时运行配置不变且草稿仍可重试。
|
||||
7. 保存不触发机器人重连、Session 重建或当前回复中断;刷新页面和重启插件后配置保持。
|
||||
8. 编辑区只编辑正文;问号可通过悬停或键盘聚焦查看使用说明和示例;“清空”仅清除正文,“填入示例”仅填入示例正文,均不改变字段和开关。
|
||||
9. 清空并保存后,刷新、重启、关闭再开启都保持为空,不自动填入示例;空字段列表同样保持为空。
|
||||
10. 关闭某类会话保留字段和正文,删除机器人时一并删除全部配置。
|
||||
11. 按钮与弹窗符合 4.7 节已确认样式;灰色“未开启”仍可点击,开启后仅状态标签使用强调色。
|
||||
12. 浅色/深色主题及 320px、360px、常规桌面宽度下不重叠、不横向溢出,最长状态标签完整显示;键盘和触控均可操作。
|
||||
|
||||
### 11.2 提示词
|
||||
|
||||
1. 当前会话类型关闭时,文本、图片和文件消息传给 Harness 的内容与当前版本一致,即使另一个会话类型已开启。
|
||||
2. 五个字段的全部 32 种选择组合均按白名单投影;未选字段不发送,勾选顺序不影响 JSON 键顺序。
|
||||
3. 取消 `conversationType` 后,群聊/私聊启用判断仍根据真实事件正确执行,不因字段未发送而失效。
|
||||
4. `senderName` 缺失时完全省略;仅选择昵称但无昵称时不发送空来源块,不补查或伪造值。
|
||||
5. 正文非空时自动添加完整的 `<dsh_im_source_guidance>` 成对标签;内置模板与自定义模板使用同一规则。
|
||||
6. 正文清空或仅含空白时不附加 guidance 块、不回退默认,但来源块仍按字段选择发送。
|
||||
7. 字段全部取消且正文非空时只附加 guidance 块;二者都为空时原消息保持不变。
|
||||
8. 多模态消息只在有增强内容时增加一个前置文本项,不生成空项;文件清单、图片和文件内容保持原有顺序与内容。
|
||||
9. 插件本地命令、Harness 控制交互和被过滤消息不附加任何增强块。
|
||||
10. 同名标签出现在模板正文中不能破坏外围封装;插件每次最多附加一个来源块和一个提示词块,不改变用户原文。
|
||||
11. 每条消息使用接收时已提交的同一配置快照;关闭后只影响后续消息,现有 Session 和历史不变。
|
||||
|
||||
### 11.3 九渠道与网络边界
|
||||
|
||||
1. 九个渠道分别使用事件 Fixture 验证五个字段的提取结果。
|
||||
2. 钉钉、Telegram、Discord、QQ 和 WhatsApp 分别覆盖昵称存在与缺失场景。
|
||||
3. 企业微信、微信、飞书、Slack 验证不会为昵称发起补查。
|
||||
4. 测试应断言普通消息处理没有新增平台 API 调用。
|
||||
5. 群聊连续由不同用户发言时,选中的 `senderId` 和可用昵称正确变化;未选发送者字段时不附加这些字段。
|
||||
6. 同时开启多个机器人时,各自的双开关、所选字段、可选 `botId` 和提示词正文正确,不串用配置。
|
||||
|
||||
### 11.4 安全与异常
|
||||
|
||||
1. 昵称包含引号、换行、标签分隔符和控制字符时仍能安全序列化。
|
||||
2. 超长昵称、发送者 ID 和机器人 ID 按约定截断或拒绝,不生成失控提示词。
|
||||
3. 配置文件缺失、字段损坏或值类型错误时安全降级为双关闭;错误保存请求不能部分更新配置。
|
||||
4. 设置保存和消息同时到达时,以消息被接收时已经提交成功的完整配置为准。
|
||||
5. 未知字段名、重复字段名、空字段列表、空白正文与超长正文按统一校验规则处理,不能意外恢复默认字段或填入示例。
|
||||
|
||||
### 11.5 关闭状态专项回归(必须通过)
|
||||
|
||||
以引入功能前的链路为基线,使用相同入站事件 Fixture、固定的时钟与 ID,以及相同的模拟 Harness 返回值,对比新实现中关闭增强时的行为。不要用真实模型两次回答是否逐字相同作为兼容判据。
|
||||
|
||||
1. 九个渠道分别覆盖旧配置无此功能、显式双关闭、仅群聊开启时的私聊、仅私聊开启时的群聊;不支持某会话类型的渠道验证对应开关不可用。
|
||||
2. 覆盖文本、图片、文件、混合内容、批量消息和含特殊空白的正文;提交内容与基线严格一致,增强入口不修改原内容对象或数组。
|
||||
3. 对比 Harness 和平台调用的参数、次数、顺序,以及 Session 映射和对外回复行为;没有新增请求、逐消息磁盘操作或异步等待。
|
||||
4. 运行现有命令、审批、问题回答、访问控制、群响应、去重、并发和流式回复测试,验证不改变原有语义。
|
||||
5. 对增强专用字段处理、序列化和模板装配设置调用计数或抛错桩;关闭状态下调用次数必须为零,原消息仍正常处理。
|
||||
6. 构造缺失或损坏的增强配置、异常的增强配置读取,以及关闭状态下无效的模板和异常来源值;不得阻断原本合法的消息,也不得自动启用增强。
|
||||
7. 打开弹窗、修改草稿、点击清空后取消,均不影响后续真实消息;只有保存成功才更新运行配置。
|
||||
8. 从开启切回关闭时,新接收的消息恢复原链路,已经接收/入队的消息遵守原配置快照;现有任务、Session 和历史不被中断、重建或清除。
|
||||
|
||||
## 12. 建议实施顺序
|
||||
|
||||
1. 增加公共配置字段、迁移兼容、状态投影和设置 RPC。
|
||||
2. 增加双开关判断、字段选择投影、空正文规则、安全序列化和提示词装配函数。
|
||||
3. 为九个渠道补齐入站事件字段提取,保留事件中已经存在但当前被丢弃的昵称。
|
||||
4. 在共享 `TextHarnessBridge` 和五个独立 Bridge 中接入统一装配函数。
|
||||
5. 增加共享机器人卡片设置入口与弹窗,接入双开关、字段选择、正文编辑、清空及保存,并覆盖九渠道卡片。
|
||||
6. 完成公共契约测试、九渠道 Fixture 测试、UI 测试和不新增平台请求的回归测试;11.5 节关闭状态对照回归必须全部通过。
|
||||
7. 在至少一个私聊渠道和一个群聊渠道进行真实客户端验收,再逐步覆盖九渠道。
|
||||
|
||||
整个实施过程中,Harness 代码和 Harness 协议保持不变。
|
||||
1305
lib/client.js
1305
lib/client.js
File diff suppressed because it is too large
Load diff
447
lib/index.js
447
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,6 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
export const DINGTALK_RPC_CHANNEL = '/dingtalk';
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: 'bot.workspace.set',
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: 'bot.context-enhancement.set',
|
||||
});
|
||||
|
||||
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
|
||||
|
|
@ -162,6 +164,7 @@ function normalizeBot(value) {
|
|||
configured: value.configured !== false,
|
||||
workspace: optionalString(value.workspace, 4_096) ?? '',
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
|
||||
bot: {
|
||||
name: optionalString(bot.name, 100) ?? '钉钉机器人',
|
||||
clientIdMasked: optionalString(bot.clientIdMasked, 140) ?? '已安全保存',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import * as React from 'react';
|
|||
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
|
||||
import { h } from '../../i18n.js';
|
||||
import { WorkspaceEditor } from '../../workspace-editor.js';
|
||||
import { ContextEnhancementEditor } from '../../context-enhancement.js';
|
||||
import {
|
||||
AgentPresetCatalogContext,
|
||||
AgentPresetEditor,
|
||||
|
|
@ -220,6 +221,7 @@ export function AccountCard({
|
|||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
onAgentPresetSave,
|
||||
onContextEnhancementSave,
|
||||
onRequestRemove,
|
||||
onConfirmRemove,
|
||||
onCancelRemove,
|
||||
|
|
@ -254,6 +256,11 @@ export function AccountCard({
|
|||
disabled: Boolean(busy),
|
||||
onSave: onAgentPresetSave,
|
||||
}),
|
||||
h(ContextEnhancementEditor, {
|
||||
config: account.contextEnhancement,
|
||||
disabled: Boolean(busy),
|
||||
onSave: onContextEnhancementSave,
|
||||
}),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
h('div', { className: 'dim-cardFooterLayout' },
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
|
|
@ -294,6 +301,7 @@ function AccountList(props) {
|
|||
onReconnect: () => props.onReconnect(account),
|
||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
|
||||
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(account, agentPreset),
|
||||
onContextEnhancementSave: (config) => props.onContextEnhancementSave(account, config),
|
||||
onRequestRemove: () => props.onRequestRemove(account),
|
||||
onConfirmRemove: () => props.onConfirmRemove(account),
|
||||
onCancelRemove: props.onCancelRemove,
|
||||
|
|
@ -737,13 +745,13 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
}
|
||||
}, [discardStaleFeedback, invoke, loadStatus, setBotBusy, workspaceFence]);
|
||||
|
||||
const saveAgentPreset = React.useCallback(async (account, agentPreset) => {
|
||||
const saveBotSetting = React.useCallback(async (account, operation, endpoint, payload) => {
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBotBusy(account.botId, 'preset');
|
||||
setBotBusy(account.botId, operation);
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(
|
||||
DINGTALK_ENDPOINTS.setAgentPreset,
|
||||
{ botId: account.botId, agentPreset },
|
||||
endpoint,
|
||||
{ botId: account.botId, ...payload },
|
||||
));
|
||||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setModel({
|
||||
|
|
@ -853,7 +861,12 @@ export function DingtalkSettingsTab({ rpcCall }) {
|
|||
removeTarget,
|
||||
onReconnect: (account) => void reconnect(account),
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
onAgentPresetSave: saveAgentPreset,
|
||||
onAgentPresetSave: (account, agentPreset) => saveBotSetting(
|
||||
account, 'preset', DINGTALK_ENDPOINTS.setAgentPreset, { agentPreset },
|
||||
),
|
||||
onContextEnhancementSave: (account, config) => saveBotSetting(
|
||||
account, 'context-enhancement', DINGTALK_ENDPOINTS.setContextEnhancement, { config },
|
||||
),
|
||||
onRequestRemove: (account) => setRemoveTarget(account.botId),
|
||||
onConfirmRemove: (account) => void remove(account),
|
||||
onCancelRemove: () => setRemoveTarget(null),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId } from "../../agent-preset.js";
|
||||
import { normalizeLastMessageError } from "../../last-message-error.js";
|
||||
import { normalizeContextEnhancementConfig } from "../../../../src/channels/shared/context-enhancement.mjs";
|
||||
|
||||
export const FEISHU_RPC_CHANNEL = "/feishu";
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
|
|||
deleteBot: "bot.delete",
|
||||
setWorkspace: "bot.workspace.set",
|
||||
setAgentPreset: "bot.preset.set",
|
||||
setContextEnhancement: "bot.context-enhancement.set",
|
||||
setGroupResponseMode: "bot.group-response-mode.set",
|
||||
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
|
||||
testConnection: "connection.test",
|
||||
|
|
@ -203,6 +205,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
|
|||
configured: value.configured !== false,
|
||||
workspace: optionalString(value.workspace)?.slice(0, 4_096) ?? "",
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
|
||||
groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
|
||||
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
||||
bot: normalizeBot(value.bot),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "./api.js";
|
||||
import { useAnimationFrameScheduler } from "../../lifecycle.js";
|
||||
import { WorkspaceEditor } from "../../workspace-editor.js";
|
||||
import { ContextEnhancementEditor } from "../../context-enhancement.js";
|
||||
import {
|
||||
AgentPresetCatalogContext,
|
||||
AgentPresetEditor,
|
||||
|
|
@ -553,6 +554,7 @@ export function BotCard({
|
|||
onRepairCallback,
|
||||
onWorkspaceSave,
|
||||
onAgentPresetSave,
|
||||
onContextEnhancementSave,
|
||||
onGroupResponseModeSave,
|
||||
onGroupMessagePermissionAuthorize,
|
||||
onRequestRemove,
|
||||
|
|
@ -611,6 +613,11 @@ export function BotCard({
|
|||
disabled: Boolean(busy),
|
||||
onSave: onAgentPresetSave,
|
||||
}),
|
||||
h(ContextEnhancementEditor, {
|
||||
config: connection.contextEnhancement,
|
||||
disabled: Boolean(busy),
|
||||
onSave: onContextEnhancementSave,
|
||||
}),
|
||||
h(GroupResponseModeEditor, {
|
||||
value: connection.groupResponseMode,
|
||||
permissionGranted: connection.groupMessagePermissionGranted,
|
||||
|
|
@ -708,6 +715,7 @@ function BotList(props) {
|
|||
onRepairCallback: () => props.onRepairCallback(bot),
|
||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
|
||||
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(bot, agentPreset),
|
||||
onContextEnhancementSave: (config) => props.onContextEnhancementSave(bot, config),
|
||||
onGroupResponseModeSave: (groupResponseMode) => props.onGroupResponseModeSave(bot, groupResponseMode),
|
||||
onGroupMessagePermissionAuthorize: () => props.onGroupMessagePermissionAuthorize(bot),
|
||||
onRequestRemove: () => props.onRequestRemove(bot),
|
||||
|
|
@ -1296,15 +1304,15 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
}
|
||||
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
|
||||
|
||||
const saveAgentPreset = React.useCallback(async (connection, agentPreset) => {
|
||||
const saveBotSetting = React.useCallback(async (connection, operation, endpoint, payload) => {
|
||||
const { botId } = connection;
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBotBusy(botId, "preset");
|
||||
setBotBusy(botId, operation);
|
||||
setBotError(botId, null);
|
||||
try {
|
||||
const snapshot = normalizeBotsSnapshot(await invoke(
|
||||
FEISHU_ENDPOINTS.setAgentPreset,
|
||||
{ botId, agentPreset },
|
||||
endpoint,
|
||||
{ botId, ...payload },
|
||||
));
|
||||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
mergeSnapshot(snapshot);
|
||||
|
|
@ -1521,7 +1529,12 @@ export function FeishuSettingsTab({ rpcCall }) {
|
|||
onReconnect: (bot) => void reconnectOneBot(bot),
|
||||
onRepairCallback: repairCallback,
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
onAgentPresetSave: saveAgentPreset,
|
||||
onAgentPresetSave: (connection, agentPreset) => saveBotSetting(
|
||||
connection, "preset", FEISHU_ENDPOINTS.setAgentPreset, { agentPreset },
|
||||
),
|
||||
onContextEnhancementSave: (connection, config) => saveBotSetting(
|
||||
connection, "context-enhancement", FEISHU_ENDPOINTS.setContextEnhancement, { config },
|
||||
),
|
||||
onGroupResponseModeSave: saveGroupResponseMode,
|
||||
onGroupMessagePermissionAuthorize: authorizeGroupMessages,
|
||||
onRequestRemove: requestRemove,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
export const QQ_RPC_CHANNEL = '/qq';
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ export const QQ_ENDPOINTS = Object.freeze({
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: 'bot.workspace.set',
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: 'bot.context-enhancement.set',
|
||||
});
|
||||
|
||||
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
|
||||
|
|
@ -87,6 +89,7 @@ function normalizeBot(value) {
|
|||
state: connected ? 'connected' : state,
|
||||
workspace: text(value.workspace, '', 4_096),
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
|
||||
bot: {
|
||||
name: text(value.bot?.name, 'QQ机器人', 100),
|
||||
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { QqLogoGlyph } from '../../channel-logos.js';
|
|||
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
|
||||
import { h } from '../../i18n.js';
|
||||
import { WorkspaceEditor } from '../../workspace-editor.js';
|
||||
import { ContextEnhancementEditor } from '../../context-enhancement.js';
|
||||
import {
|
||||
AgentPresetCatalogContext,
|
||||
AgentPresetEditor,
|
||||
|
|
@ -166,6 +167,7 @@ export function AccountCard({
|
|||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
onAgentPresetSave,
|
||||
onContextEnhancementSave,
|
||||
onRequestRemove,
|
||||
onConfirmRemove,
|
||||
onCancelRemove,
|
||||
|
|
@ -198,6 +200,11 @@ export function AccountCard({
|
|||
disabled: Boolean(busy),
|
||||
onSave: onAgentPresetSave,
|
||||
}),
|
||||
h(ContextEnhancementEditor, {
|
||||
config: account.contextEnhancement,
|
||||
disabled: Boolean(busy),
|
||||
onSave: onContextEnhancementSave,
|
||||
}),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
h('div', { className: 'dim-cardFooterLayout' },
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
|
|
@ -468,6 +475,12 @@ export function QqSettingsTab({ rpcCall }) {
|
|||
QQ_ENDPOINTS.setAgentPreset,
|
||||
{ botId: account.botId, agentPreset },
|
||||
),
|
||||
onContextEnhancementSave: (config) => botAction(
|
||||
account,
|
||||
'context-enhancement',
|
||||
QQ_ENDPOINTS.setContextEnhancement,
|
||||
{ botId: account.botId, config },
|
||||
),
|
||||
onRequestRemove: () => setRemoveTarget(account.botId),
|
||||
onCancelRemove: () => setRemoveTarget(null),
|
||||
onConfirmRemove: async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: 'bot.workspace.set',
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: 'bot.context-enhancement.set',
|
||||
});
|
||||
|
||||
export function createTokenChannelApi(channel, connectionSummary, {
|
||||
|
|
@ -57,6 +59,7 @@ export function createTokenChannelApi(channel, connectionSummary, {
|
|||
state: connected ? 'connected' : state,
|
||||
workspace: text(value.workspace, '', 4_096),
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
|
||||
bot: {
|
||||
name: text(value.bot?.name, `${channel}机器人`, 100),
|
||||
username: text(value.bot?.username, '', 100),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { CredentialActionIcon, CredentialBindingPanel } from '../../credential-b
|
|||
import { h } from '../../i18n.js';
|
||||
import { installDingtalkStyles } from '../dingtalk/styles.js';
|
||||
import { WorkspaceEditor } from '../../workspace-editor.js';
|
||||
import { ContextEnhancementEditor } from '../../context-enhancement.js';
|
||||
import {
|
||||
AgentPresetCatalogContext,
|
||||
AgentPresetEditor,
|
||||
|
|
@ -73,7 +74,7 @@ export function createTokenChannelSettings(definition) {
|
|||
accountSettingsEndpoint = null,
|
||||
} = definition;
|
||||
|
||||
function AccountCard({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onAgentPresetSave, onAccountSettingsSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
function AccountCard({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onAgentPresetSave, onContextEnhancementSave, onAccountSettingsSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
const state = busy === 'reconnect' ? 'connecting' : account.state;
|
||||
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
|
||||
const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
|
||||
|
|
@ -105,6 +106,11 @@ export function createTokenChannelSettings(definition) {
|
|||
disabled: Boolean(busy),
|
||||
onSave: onAgentPresetSave,
|
||||
}),
|
||||
h(ContextEnhancementEditor, {
|
||||
config: account.contextEnhancement,
|
||||
disabled: Boolean(busy),
|
||||
onSave: onContextEnhancementSave,
|
||||
}),
|
||||
AccountSettings ? h(AccountSettings, {
|
||||
account,
|
||||
busy: Boolean(busy),
|
||||
|
|
@ -309,6 +315,12 @@ export function createTokenChannelSettings(definition) {
|
|||
endpoints.setAgentPreset,
|
||||
{ botId: account.botId, agentPreset },
|
||||
),
|
||||
onContextEnhancementSave: (config) => botAction(
|
||||
account,
|
||||
'context-enhancement',
|
||||
endpoints.setContextEnhancement,
|
||||
{ botId: account.botId, config },
|
||||
),
|
||||
onAccountSettingsSave: AccountSettings && accountSettingsEndpoint
|
||||
? (payload) => botAction(
|
||||
account,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
export const WECOM_RPC_CHANNEL = '/wecom';
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: 'bot.workspace.set',
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: 'bot.context-enhancement.set',
|
||||
});
|
||||
|
||||
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
|
||||
|
|
@ -96,6 +98,7 @@ function normalizeBot(value) {
|
|||
state: connected ? 'connected' : state,
|
||||
workspace: text(value.workspace, '', 4_096),
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
|
||||
bot: {
|
||||
name: text(value.bot?.name, '企业微信机器人', 100),
|
||||
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { WecomLogoGlyph } from '../../channel-logos.js';
|
|||
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
|
||||
import { h } from '../../i18n.js';
|
||||
import { WorkspaceEditor } from '../../workspace-editor.js';
|
||||
import { ContextEnhancementEditor } from '../../context-enhancement.js';
|
||||
import {
|
||||
AgentPresetCatalogContext,
|
||||
AgentPresetEditor,
|
||||
|
|
@ -165,6 +166,7 @@ export function AccountCard({
|
|||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
onAgentPresetSave,
|
||||
onContextEnhancementSave,
|
||||
onRequestRemove,
|
||||
onConfirmRemove,
|
||||
onCancelRemove,
|
||||
|
|
@ -197,6 +199,11 @@ export function AccountCard({
|
|||
disabled: Boolean(busy),
|
||||
onSave: onAgentPresetSave,
|
||||
}),
|
||||
h(ContextEnhancementEditor, {
|
||||
config: account.contextEnhancement,
|
||||
disabled: Boolean(busy),
|
||||
onSave: onContextEnhancementSave,
|
||||
}),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
h('div', { className: 'dim-cardFooterLayout' },
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
|
|
@ -499,6 +506,12 @@ export function WecomSettingsTab({ rpcCall }) {
|
|||
WECOM_ENDPOINTS.setAgentPreset,
|
||||
{ botId: account.botId, agentPreset },
|
||||
),
|
||||
onContextEnhancementSave: (config) => botAction(
|
||||
account,
|
||||
'context-enhancement',
|
||||
WECOM_ENDPOINTS.setContextEnhancement,
|
||||
{ botId: account.botId, config },
|
||||
),
|
||||
onRequestRemove: () => setRemoveTarget(account.botId),
|
||||
onCancelRemove: () => setRemoveTarget(null),
|
||||
onConfirmRemove: async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
export const WEIXIN_RPC_CHANNEL = '/weixin';
|
||||
export const WEIXIN_ENDPOINTS = Object.freeze({
|
||||
|
|
@ -12,6 +13,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: 'bot.workspace.set',
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: 'bot.context-enhancement.set',
|
||||
});
|
||||
|
||||
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
|
||||
|
|
@ -121,6 +123,7 @@ function normalizeBot(value) {
|
|||
configured: value.configured === true,
|
||||
workspace: string(value.workspace).slice(0, 4_096),
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
|
||||
bot: {
|
||||
name: string(value.bot.name, '微信机器人'),
|
||||
accountIdMasked: string(value.bot.accountIdMasked, '已安全保存'),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from './api.js';
|
||||
import { createPollScheduler, useAnimationFrameScheduler } from '../../lifecycle.js';
|
||||
import { WorkspaceEditor } from '../../workspace-editor.js';
|
||||
import { ContextEnhancementEditor } from '../../context-enhancement.js';
|
||||
import {
|
||||
AgentPresetCatalogContext,
|
||||
AgentPresetEditor,
|
||||
|
|
@ -207,6 +208,7 @@ export function AccountCard({
|
|||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
onAgentPresetSave,
|
||||
onContextEnhancementSave,
|
||||
onRequestRemove,
|
||||
onConfirmRemove,
|
||||
onCancelRemove,
|
||||
|
|
@ -238,6 +240,12 @@ export function AccountCard({
|
|||
disabled: Boolean(busy),
|
||||
onSave: onAgentPresetSave,
|
||||
}),
|
||||
h(ContextEnhancementEditor, {
|
||||
config: account.contextEnhancement,
|
||||
groupSupported: false,
|
||||
disabled: Boolean(busy),
|
||||
onSave: onContextEnhancementSave,
|
||||
}),
|
||||
h('div', { className: 'dxw-accountFooter dim-cardFooter' },
|
||||
h('div', { className: 'dim-cardFooterLayout' },
|
||||
h('div', { className: 'dxw-actions dim-cardActions' },
|
||||
|
|
@ -280,6 +288,7 @@ function AccountList(props) {
|
|||
onReconnect: () => props.onReconnect(account),
|
||||
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
|
||||
onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(account, agentPreset),
|
||||
onContextEnhancementSave: (config) => props.onContextEnhancementSave(account, config),
|
||||
onRequestRemove: () => props.onRequestRemove(account),
|
||||
onConfirmRemove: () => props.onConfirmRemove(account),
|
||||
onCancelRemove: props.onCancelRemove,
|
||||
|
|
@ -594,13 +603,13 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
}
|
||||
}, [invoke, loadStatus, setBotBusy, workspaceFence]);
|
||||
|
||||
const saveAgentPreset = React.useCallback(async (account, agentPreset) => {
|
||||
const saveBotSetting = React.useCallback(async (account, operation, endpoint, payload) => {
|
||||
const snapshotVersion = workspaceFence.beginMutation();
|
||||
setBotBusy(account.botId, 'preset');
|
||||
setBotBusy(account.botId, operation);
|
||||
try {
|
||||
const snapshot = normalizeSnapshot(await invoke(
|
||||
WEIXIN_ENDPOINTS.setAgentPreset,
|
||||
{ botId: account.botId, agentPreset },
|
||||
endpoint,
|
||||
{ botId: account.botId, ...payload },
|
||||
));
|
||||
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
|
||||
setModel({
|
||||
|
|
@ -703,7 +712,12 @@ export function WeixinSettingsTab({ rpcCall }) {
|
|||
removeTarget,
|
||||
onReconnect: (account) => void reconnect(account),
|
||||
onWorkspaceSave: saveWorkspace,
|
||||
onAgentPresetSave: saveAgentPreset,
|
||||
onAgentPresetSave: (account, agentPreset) => saveBotSetting(
|
||||
account, 'preset', WEIXIN_ENDPOINTS.setAgentPreset, { agentPreset },
|
||||
),
|
||||
onContextEnhancementSave: (account, config) => saveBotSetting(
|
||||
account, 'context-enhancement', WEIXIN_ENDPOINTS.setContextEnhancement, { config },
|
||||
),
|
||||
onRequestRemove: (account) => setRemoveTarget(account.botId),
|
||||
onConfirmRemove: (account) => void remove(account),
|
||||
onCancelRemove: () => setRemoveTarget(null),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
|
||||
import { normalizeLastMessageError } from '../../last-message-error.js';
|
||||
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
export const WHATSAPP_RPC_CHANNEL = '/whatsapp';
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
|
|||
setAccessPolicy: 'bot.access-policy.set',
|
||||
setWorkspace: 'bot.workspace.set',
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: 'bot.context-enhancement.set',
|
||||
});
|
||||
|
||||
const PROVISION_STATES = new Set(['starting', 'pending', 'connecting', 'connected', 'failed', 'cancelled']);
|
||||
|
|
@ -88,6 +90,7 @@ function normalizeBot(value) {
|
|||
state: connected ? 'connected' : state,
|
||||
workspace: text(value.workspace, '', 4_096),
|
||||
agentPreset: normalizeAgentPresetId(value.agentPreset),
|
||||
contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
|
||||
accessPolicy: {
|
||||
accessMode: ['self-only', 'private-allowlist', 'open'].includes(
|
||||
value.accessPolicy?.accessMode,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { WhatsappLogoGlyph } from '../../channel-logos.js';
|
|||
import { QrActionIcon } from '../../credential-binding.js';
|
||||
import { h } from '../../i18n.js';
|
||||
import { WorkspaceEditor } from '../../workspace-editor.js';
|
||||
import { ContextEnhancementEditor } from '../../context-enhancement.js';
|
||||
import {
|
||||
AgentPresetCatalogContext,
|
||||
AgentPresetEditor,
|
||||
|
|
@ -297,6 +298,7 @@ export function WhatsappAccountCard({
|
|||
onReconnect,
|
||||
onWorkspaceSave,
|
||||
onAgentPresetSave,
|
||||
onContextEnhancementSave,
|
||||
onAccessPolicySave,
|
||||
onRequestRemove,
|
||||
onConfirmRemove,
|
||||
|
|
@ -334,6 +336,11 @@ export function WhatsappAccountCard({
|
|||
disabled: Boolean(busy),
|
||||
onSave: onAgentPresetSave,
|
||||
}),
|
||||
h(ContextEnhancementEditor, {
|
||||
config: account.contextEnhancement,
|
||||
disabled: Boolean(busy),
|
||||
onSave: onContextEnhancementSave,
|
||||
}),
|
||||
h(WhatsappAccessSettings, {
|
||||
account,
|
||||
busy: Boolean(busy),
|
||||
|
|
@ -596,6 +603,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
|
|||
WHATSAPP_ENDPOINTS.setAgentPreset,
|
||||
{ botId: account.botId, agentPreset },
|
||||
),
|
||||
onContextEnhancementSave: (config) => botAction(
|
||||
account,
|
||||
'context-enhancement',
|
||||
WHATSAPP_ENDPOINTS.setContextEnhancement,
|
||||
{ botId: account.botId, config },
|
||||
),
|
||||
onAccessPolicySave: (accessPolicy) => botAction(
|
||||
account,
|
||||
'access',
|
||||
|
|
|
|||
275
plugin-src/client/context-enhancement.js
Normal file
275
plugin-src/client/context-enhancement.js
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import * as React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
CONTEXT_ENHANCEMENT_FIELDS,
|
||||
CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH,
|
||||
CONTEXT_GUIDANCE_EXAMPLE,
|
||||
normalizeContextEnhancementConfig,
|
||||
validateContextEnhancementConfig,
|
||||
} from '../../src/channels/shared/context-enhancement.mjs';
|
||||
import { h, localizeText } from './i18n.js';
|
||||
|
||||
const FIELD_LABELS = Object.freeze({
|
||||
channel: '渠道',
|
||||
conversationType: '会话类型',
|
||||
senderId: '发送者标识',
|
||||
senderName: '发送者昵称',
|
||||
botId: '机器人标识',
|
||||
});
|
||||
|
||||
export function contextEnhancementLabel(config) {
|
||||
const { groupEnabled, directEnabled } = normalizeContextEnhancementConfig(config);
|
||||
if (groupEnabled && directEnabled) return '群聊和私聊';
|
||||
if (groupEnabled) return '仅群聊';
|
||||
if (directEnabled) return '仅私聊';
|
||||
return '未开启';
|
||||
}
|
||||
|
||||
function ContextIcon({ kind = 'sliders' }) {
|
||||
const path = kind === 'close' ? 'M6 6l12 12M6 18 18 6'
|
||||
: kind === 'chevron' ? 'm9 5 7 7-7 7'
|
||||
: 'M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-3M14 20H3M14 2v4M8 10v4M18 18v4';
|
||||
return h('svg', {
|
||||
width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none',
|
||||
stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true', focusable: 'false',
|
||||
}, h('path', { d: path }));
|
||||
}
|
||||
|
||||
function ContextEnhancementDialog({ config, groupSupported, disabled, onSave, onClose, returnFocusRef, id }) {
|
||||
// A mounted dialog owns its draft; status refreshes must not replace unsaved edits.
|
||||
const [draft, setDraft] = React.useState(() => ({
|
||||
...normalizeContextEnhancementConfig(config),
|
||||
...(!groupSupported ? { groupEnabled: false } : {}),
|
||||
}));
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState(null);
|
||||
const savingRef = React.useRef(false);
|
||||
const dialogRef = React.useRef(null);
|
||||
const mountedRef = React.useRef(true);
|
||||
const titleId = React.useId();
|
||||
const descriptionId = React.useId();
|
||||
const groupNoticeId = React.useId();
|
||||
const guidanceId = React.useId();
|
||||
const guidanceHelpId = React.useId();
|
||||
const fieldIdPrefix = React.useId();
|
||||
const fieldsHelpId = React.useId();
|
||||
const senderNameHelpId = React.useId();
|
||||
const guidanceExample = localizeText(CONTEXT_GUIDANCE_EXAMPLE);
|
||||
const busy = disabled || saving;
|
||||
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
dialogRef.current?.focus?.();
|
||||
const keepFocus = (event) => {
|
||||
if (dialogRef.current && !dialogRef.current.contains(event.target)) dialogRef.current.focus();
|
||||
};
|
||||
globalThis.document?.addEventListener?.('focusin', keepFocus);
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
globalThis.document?.removeEventListener?.('focusin', keepFocus);
|
||||
queueMicrotask(() => returnFocusRef.current?.focus?.());
|
||||
};
|
||||
}, [returnFocusRef]);
|
||||
|
||||
const change = (key, value) => {
|
||||
if (busy || savingRef.current) return;
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (!savingRef.current) onClose();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (busy || savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
dialogRef.current?.focus?.();
|
||||
try {
|
||||
const next = validateContextEnhancementConfig(draft);
|
||||
await onSave(next);
|
||||
if (mountedRef.current) onClose();
|
||||
} catch (cause) {
|
||||
if (mountedRef.current) setError(cause?.message ?? '上下文增强保存失败,请重试。');
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
if (mountedRef.current) setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const content = h('div', {
|
||||
className: 'dim-contextBackdrop',
|
||||
onMouseDown: (event) => { if (event.target === event.currentTarget) cancel(); },
|
||||
}, h('section', {
|
||||
id,
|
||||
ref: dialogRef,
|
||||
className: 'dim-contextDialog',
|
||||
role: 'dialog',
|
||||
'aria-modal': 'true',
|
||||
'aria-labelledby': titleId,
|
||||
'aria-describedby': descriptionId,
|
||||
'aria-busy': saving,
|
||||
tabIndex: -1,
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cancel();
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = dialogRef.current?.querySelectorAll?.(
|
||||
'button:not(:disabled), input:not(:disabled), textarea:not(:disabled)',
|
||||
);
|
||||
if (!controls?.length) {
|
||||
event.preventDefault();
|
||||
dialogRef.current?.focus?.();
|
||||
return;
|
||||
}
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
const active = globalThis.document?.activeElement;
|
||||
if (event.shiftKey && (active === first || active === dialogRef.current)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (active === last || active === dialogRef.current)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
h('header', { className: 'dim-contextHeader' },
|
||||
h('div', { className: 'dim-contextHeaderTitle' },
|
||||
h('h3', { id: titleId }, '上下文增强'),
|
||||
h('span', { className: 'dim-contextHelp dim-contextHeaderHelp' },
|
||||
h('button', {
|
||||
type: 'button', className: 'dim-contextHelpButton', disabled: busy,
|
||||
'aria-label': '查看上下文增强说明', 'aria-describedby': descriptionId,
|
||||
}, h('span', { 'aria-hidden': 'true' }, '?')),
|
||||
h('span', { id: descriptionId, className: 'dim-contextTooltip dim-contextHeaderTooltip', role: 'tooltip' },
|
||||
'选择在哪些会话中启用、提供哪些来源字段,以及如何使用这些信息。仅使用已有消息元数据,不查询平台 API。'))),
|
||||
h('button', {
|
||||
type: 'button', className: 'dim-contextClose', 'aria-label': '关闭弹窗',
|
||||
disabled: saving, onClick: cancel,
|
||||
}, h(ContextIcon, { kind: 'close' }))),
|
||||
h('fieldset', { className: 'dim-contextSection', disabled: busy },
|
||||
h('legend', null, '启用范围'),
|
||||
h('div', { className: 'dim-contextSwitches' },
|
||||
h('label', { className: 'dim-contextSwitchRow' },
|
||||
h('span', { className: 'dim-contextSwitchLabel' },
|
||||
h('span', null, '群聊中启用'),
|
||||
!groupSupported ? h('span', {
|
||||
id: groupNoticeId, className: 'dim-contextUnavailable',
|
||||
}, '(当前渠道不支持群聊)') : null),
|
||||
h('input', {
|
||||
type: 'checkbox', role: 'switch', className: 'dim-contextSwitch',
|
||||
checked: groupSupported && draft.groupEnabled,
|
||||
disabled: busy || !groupSupported,
|
||||
'aria-describedby': !groupSupported ? groupNoticeId : undefined,
|
||||
onChange: (event) => { if (groupSupported) change('groupEnabled', event.target.checked); },
|
||||
})),
|
||||
h('label', { className: 'dim-contextSwitchRow' },
|
||||
h('span', null, '私聊中启用'),
|
||||
h('input', {
|
||||
type: 'checkbox', role: 'switch', className: 'dim-contextSwitch',
|
||||
checked: draft.directEnabled, disabled: busy,
|
||||
onChange: (event) => change('directEnabled', event.target.checked),
|
||||
}))),
|
||||
),
|
||||
h('fieldset', { className: 'dim-contextSection', disabled: busy },
|
||||
h('legend', { className: 'dim-contextLegend' },
|
||||
h('span', null, '来源字段'),
|
||||
h('span', { className: 'dim-contextHelp dim-contextLegendHelp' },
|
||||
h('button', {
|
||||
type: 'button', className: 'dim-contextHelpButton', disabled: busy,
|
||||
'aria-label': '查看来源字段说明', 'aria-describedby': fieldsHelpId,
|
||||
}, h('span', { 'aria-hidden': 'true' }, '?')),
|
||||
h('span', { id: fieldsHelpId, className: 'dim-contextTooltip dim-contextLegendTooltip', role: 'tooltip' },
|
||||
'增强提示词中请使用字段名(如 senderId、conversationType)引用这些信息。只发送勾选且当前消息中可用的字段,不会额外查询或补全。'))),
|
||||
h('div', { className: 'dim-contextFields' }, CONTEXT_ENHANCEMENT_FIELDS.map((field) => {
|
||||
const fieldId = `${fieldIdPrefix}-${field}`;
|
||||
return h('div', { key: field, className: 'dim-contextField' },
|
||||
h('input', {
|
||||
id: fieldId, type: 'checkbox', name: field,
|
||||
checked: draft.fields.includes(field), disabled: busy,
|
||||
onChange: (event) => change('fields', event.target.checked
|
||||
? [...draft.fields, field] : draft.fields.filter((value) => value !== field)),
|
||||
}),
|
||||
h('span', { className: 'dim-contextFieldText' },
|
||||
h('label', { className: 'dim-contextFieldName', htmlFor: fieldId }, FIELD_LABELS[field]),
|
||||
field === 'senderName' ? h('span', { className: 'dim-contextHelp dim-contextFieldHelp' },
|
||||
h('button', {
|
||||
type: 'button', className: 'dim-contextHelpButton dim-contextFieldHelpButton', disabled: busy,
|
||||
'aria-label': '查看发送者昵称字段说明', 'aria-describedby': senderNameHelpId,
|
||||
}, h('span', { 'aria-hidden': 'true' }, '?')),
|
||||
h('span', {
|
||||
id: senderNameHelpId,
|
||||
className: 'dim-contextTooltip dim-contextFieldTooltip',
|
||||
role: 'tooltip',
|
||||
}, '该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段,<dsh_im_source> 中也会省略 senderName。')) : null,
|
||||
h('label', { className: 'dim-contextFieldKey', htmlFor: fieldId }, field)));
|
||||
}))),
|
||||
h('div', { className: 'dim-contextGuidance' },
|
||||
h('div', { className: 'dim-contextEditorHeader' },
|
||||
h('span', { className: 'dim-contextEditorTitle' },
|
||||
h('label', { htmlFor: guidanceId }, '增强提示词'),
|
||||
h('span', { className: 'dim-contextHelp' },
|
||||
h('button', {
|
||||
type: 'button', className: 'dim-contextHelpButton', disabled: busy,
|
||||
'aria-label': '查看增强提示词使用说明', 'aria-describedby': guidanceHelpId,
|
||||
}, h('span', { 'aria-hidden': 'true' }, '?')),
|
||||
h('span', { id: guidanceHelpId, className: 'dim-contextTooltip dim-contextGuidanceTooltip', role: 'tooltip' },
|
||||
h('strong', null, '使用说明'),
|
||||
h('span', null, '用于告诉模型如何使用 <dsh_im_source> 中已选择的来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。'),
|
||||
h('strong', null, '生效规则'),
|
||||
h('span', null, '只需填写正文,插件自动添加 <dsh_im_source_guidance> 成对标签。清空并保存后,不再附加增强提示词,已选来源字段仍按开关设置发送。'),
|
||||
h('strong', null, '隐私提示'),
|
||||
h('span', null, '发送者标识可能包含平台用户 ID 或电话号码形式的标识。关闭开关不会删除已经写入会话历史的信息。'),
|
||||
h('strong', null, '使用示例'),
|
||||
h('span', { className: 'dim-contextTooltipExample' }, guidanceExample)))),
|
||||
h('div', { className: 'dim-contextTextActions' },
|
||||
h('button', { type: 'button', disabled: busy, onClick: () => change('guidance', guidanceExample) }, '填入示例'),
|
||||
h('button', { type: 'button', disabled: busy, onClick: () => change('guidance', '') }, '清空'))),
|
||||
h('textarea', {
|
||||
id: guidanceId, value: draft.guidance, placeholder: guidanceExample, rows: 4, disabled: busy,
|
||||
maxLength: CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH,
|
||||
'aria-describedby': guidanceHelpId,
|
||||
onChange: (event) => change('guidance', event.target.value),
|
||||
})),
|
||||
error ? h('p', { className: 'dim-contextError', role: 'alert' }, error) : null,
|
||||
h('footer', { className: 'dim-contextFooter' },
|
||||
h('button', { type: 'button', disabled: saving, onClick: cancel }, '取消'),
|
||||
h('button', {
|
||||
type: 'button', className: 'dim-contextSave', disabled: busy,
|
||||
onClick: () => { void save(); },
|
||||
}, saving ? '保存中…' : '保存'))));
|
||||
|
||||
return globalThis.document?.body ? createPortal(content, document.body) : content;
|
||||
}
|
||||
|
||||
export function ContextEnhancementEditor({ config, groupSupported = true, disabled = false, onSave }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const entryRef = React.useRef(null);
|
||||
const dialogId = React.useId();
|
||||
const statusId = React.useId();
|
||||
const saved = normalizeContextEnhancementConfig(config);
|
||||
const label = contextEnhancementLabel(groupSupported ? saved : { ...saved, groupEnabled: false });
|
||||
|
||||
return h(React.Fragment, null,
|
||||
h('button', {
|
||||
type: 'button', ref: entryRef, className: 'dim-contextEntry', disabled,
|
||||
'aria-label': '上下文增强', 'aria-describedby': statusId,
|
||||
'aria-haspopup': 'dialog', 'aria-expanded': open, 'aria-controls': open ? dialogId : undefined,
|
||||
onClick: () => setOpen(true),
|
||||
}, h(ContextIcon),
|
||||
h('span', { className: 'dim-contextLabel' }, '上下文增强'),
|
||||
h('span', { id: statusId, className: 'dim-contextStatus', 'data-active': label !== '未开启', 'aria-live': 'polite' }, label),
|
||||
h(ContextIcon, { kind: 'chevron' })),
|
||||
open ? h(ContextEnhancementDialog, {
|
||||
id: dialogId, config, groupSupported, disabled, onSave,
|
||||
onClose: () => setOpen(false), returnFocusRef: entryRef,
|
||||
}) : null);
|
||||
}
|
||||
|
|
@ -208,6 +208,44 @@ const EN = Object.freeze({
|
|||
'/绝对路径/到/工作区': '/absolute/path/to/workspace',
|
||||
'修改': 'Change',
|
||||
'保存': 'Save',
|
||||
'上下文增强': 'Context enhancement',
|
||||
'未开启': 'Not enabled',
|
||||
'仅群聊': 'Groups only',
|
||||
'仅私聊': 'Direct only',
|
||||
'群聊和私聊': 'Groups and direct',
|
||||
'关闭弹窗': 'Close dialog',
|
||||
'查看上下文增强说明': 'View context enhancement details',
|
||||
'启用范围': 'Enable in',
|
||||
'群聊中启用': 'Enable in group chats',
|
||||
'私聊中启用': 'Enable in direct chats',
|
||||
'(当前渠道不支持群聊)': '(This channel does not support group chats)',
|
||||
'来源字段': 'Source fields',
|
||||
'查看来源字段说明': 'View source field details',
|
||||
'渠道': 'Channel',
|
||||
'会话类型': 'Conversation type',
|
||||
'发送者标识': 'Sender ID',
|
||||
'发送者昵称': 'Sender name',
|
||||
'查看发送者昵称字段说明': 'View sender name field details',
|
||||
'该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段,<dsh_im_source> 中也会省略 senderName。': 'This field is not available on every channel. If the current message has no sender name, <dsh_im_source> omits senderName even when the field is selected.',
|
||||
'机器人标识': 'Bot ID',
|
||||
'增强提示词': 'Guidance',
|
||||
'查看增强提示词使用说明': 'View guidance instructions',
|
||||
'使用说明': 'How to use',
|
||||
'用于告诉模型如何使用 <dsh_im_source> 中已选择的来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。': 'Tell the model how to use the selected fields in <dsh_im_source>. Enter only the body; the plugin adds paired <dsh_im_source_guidance> tags.',
|
||||
'生效规则': 'Behavior',
|
||||
'隐私提示': 'Privacy',
|
||||
'使用示例': 'Example',
|
||||
'仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。\nconversationType是群聊时回复严肃一点,conversationType是私聊时回复一定要幽默搞笑,像周星驰的电影一样搞笑': 'Understand the source only from fields actually present in <dsh_im_source> for the current message; do not guess or fill in missing fields.\nWhen conversationType is group, reply more seriously. When conversationType is direct, make the reply humorous and funny.',
|
||||
'填入示例': 'Use example',
|
||||
'清空': 'Clear',
|
||||
'选择在哪些会话中启用、提供哪些来源字段,以及如何使用这些信息。仅使用已有消息元数据,不查询平台 API。': 'Choose which conversations to enhance, which source fields to include, and how to use them. Only existing message metadata is used; no platform APIs are queried.',
|
||||
'增强提示词中请使用字段名(如 senderId、conversationType)引用这些信息。只发送勾选且当前消息中可用的字段,不会额外查询或补全。': 'Reference these values by field name (such as senderId or conversationType) in the guidance. Only selected fields available in the current message are sent; no extra lookups are made.',
|
||||
'只需填写正文,插件自动添加 <dsh_im_source_guidance> 成对标签。清空并保存后,不再附加增强提示词,已选来源字段仍按开关设置发送。': 'Enter only the body; the plugin adds paired <dsh_im_source_guidance> tags. Clear and save to omit guidance. Selected source fields still follow the conversation switches.',
|
||||
'发送者标识可能包含平台用户 ID 或电话号码形式的标识。关闭开关不会删除已经写入会话历史的信息。': 'Sender IDs may contain platform user IDs or phone-number-like identifiers. Turning this off does not remove information already saved in conversation history.',
|
||||
'上下文增强保存失败,请重试。': 'Could not save context enhancement. Try again.',
|
||||
'请提交完整的上下文增强设置。': 'Submit the complete context enhancement settings.',
|
||||
'群聊和私聊开关必须是布尔值。': 'Group and direct switches must be booleans.',
|
||||
'来源字段只能选择已定义的五个字段。': 'Source fields must be chosen from the five defined fields.',
|
||||
'保存中…': 'Saving…',
|
||||
'未设置': 'Not set',
|
||||
'工作区修改失败,请重试。': 'Could not update the workspace. Try again.',
|
||||
|
|
@ -657,6 +695,8 @@ function channelName(value) {
|
|||
}
|
||||
|
||||
function translateDynamic(text) {
|
||||
const guidanceLimit = /^增强提示词不得超过 (\d+) 个字符。$/.exec(text);
|
||||
if (guidanceLimit) return `Guidance must not exceed ${guidanceLimit[1]} characters.`;
|
||||
let match = /^(\d+) \/ (\d+) 在线$/.exec(text);
|
||||
if (match) return `${match[1]}/${match[2]} online`;
|
||||
match = /^已接入 (\d+) 个机器人,其中 (\d+) 个在线$/.exec(text);
|
||||
|
|
|
|||
|
|
@ -211,6 +211,73 @@ const CSS = String.raw`
|
|||
.dim-panel .dim-presetSelect { min-width: 0; max-width: 100%; grid-column: 1 / -1; grid-row: 2; height: 30px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; }
|
||||
.dim-panel .dim-presetSelect:disabled { cursor: not-allowed; opacity: .55; }
|
||||
.dim-panel .dim-presetError { grid-column: 1 / -1; grid-row: 3; margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.4; }
|
||||
.dim-contextEntry { width: 100%; min-height: 40px; display: grid; grid-template-columns: 16px minmax(0, 1fr) max-content 16px; align-items: center; gap: 9px; margin: 10px 0; padding: 8px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-module-platform, #f7f8fa); font: inherit; font-size: 13px; line-height: 20px; text-align: left; cursor: pointer; }
|
||||
.dim-contextEntry:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
|
||||
.dim-contextEntry > svg { color: var(--dsw-alias-label-secondary, #646a73); }
|
||||
.dim-contextLabel { min-width: 0; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.dim-contextStatus { padding: 2px 7px; border-radius: 5px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-layer-1, #fff); font-size: 11px; line-height: 16px; font-weight: 400; white-space: nowrap; }
|
||||
.dim-contextStatus[data-active="true"] { color: var(--dsw-alias-state-business-primary, #3370ff); background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 10%, var(--dsw-alias-bg-layer-1, #fff)); }
|
||||
.dim-contextBackdrop { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 12px; background: rgb(15 17 21 / 42%); }
|
||||
.dim-contextBackdrop, .dim-contextBackdrop *, .dim-contextBackdrop *::before, .dim-contextBackdrop *::after { box-sizing: border-box; }
|
||||
.dim-contextDialog { width: min(450px, 100%); min-width: 0; max-height: calc(100vh - 24px); max-height: calc(100dvh - 24px); overflow-y: auto; padding: 16px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 20px 70px rgb(0 0 0 / 20%); font-size: 13px; line-height: 1.5; text-align: left; }
|
||||
.dim-contextDialog:focus { outline: none; }
|
||||
.dim-contextHeader, .dim-contextEditorHeader { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.dim-contextHeader { position: relative; }
|
||||
.dim-contextHeaderTitle { min-width: 0; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.dim-contextHeader h3 { margin: 0; font-size: 15px; line-height: 22px; font-weight: 500; }
|
||||
.dim-contextHeaderTooltip { width: min(340px, calc(100vw - 72px)); }
|
||||
.dim-contextClose { width: 30px; height: 30px; flex: none; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; cursor: pointer; }
|
||||
.dim-contextClose:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
|
||||
.dim-contextSection { min-width: 0; margin: 14px 0; padding: 0; border: 0; }
|
||||
.dim-contextSection legend { margin-bottom: 8px; padding: 0; font-weight: 500; }
|
||||
.dim-contextLegend { position: relative; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.dim-contextSwitches { display: grid; grid-template-columns: 1fr; gap: 2px; }
|
||||
.dim-contextSwitchRow { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 34px; cursor: pointer; }
|
||||
.dim-contextSwitchLabel { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; flex-wrap: wrap; }
|
||||
.dim-contextUnavailable { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 16px; font-weight: 400; }
|
||||
.dim-contextSwitch { appearance: none; flex: none; width: 32px; height: 19px; margin: 0; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; background: var(--dsw-alias-interactive-bg-hover, #eef0f3); cursor: pointer; }
|
||||
.dim-contextSwitch::before { content: ""; display: block; width: 13px; height: 13px; margin: 2px; border-radius: 50%; background: var(--dsw-alias-label-secondary, #646a73); }
|
||||
.dim-contextSwitch:checked { border-color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-state-business-primary, #3370ff); }
|
||||
.dim-contextSwitch:checked::before { transform: translateX(13px); background: #fff; }
|
||||
.dim-contextFields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px 12px; }
|
||||
.dim-contextField { min-width: 0; min-height: 30px; display: flex; align-items: center; gap: 6px; }
|
||||
.dim-contextField input { flex: none; width: 14px; height: 14px; margin: 0; accent-color: var(--dsw-alias-state-business-primary, #3370ff); }
|
||||
.dim-contextFieldText { min-width: 0; display: grid; grid-template-columns: max-content max-content; align-items: center; column-gap: 5px; overflow-wrap: anywhere; }
|
||||
.dim-contextFieldName { min-width: 0; line-height: 17px; cursor: pointer; }
|
||||
.dim-contextFieldKey { min-width: 0; grid-column: 1 / -1; color: var(--dsw-alias-label-tertiary, #8f959e); font: 10px/14px ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; cursor: pointer; }
|
||||
.dim-contextFieldHelp { position: relative; }
|
||||
.dim-contextFieldHelpButton { width: 16px; height: 16px; font-size: 10px; }
|
||||
.dim-contextTooltip.dim-contextFieldTooltip { top: calc(100% + 6px); right: 0; left: auto; width: min(280px, calc(100vw - 72px)); }
|
||||
.dim-contextEditorHeader { position: relative; flex-wrap: wrap; }
|
||||
.dim-contextEditorTitle { min-width: 0; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.dim-contextEditorTitle > label { font-weight: 500; }
|
||||
.dim-contextHelp { display: inline-flex; align-items: center; flex: none; }
|
||||
.dim-contextHelpButton { width: 18px; height: 18px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 30%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 11px; line-height: 1; font-weight: 700; cursor: help; transition: border-color .15s ease, color .15s ease, background .15s ease, box-shadow .15s ease; }
|
||||
.dim-contextHelpButton:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 8%, var(--dsw-alias-bg-layer-1, #fff)); }
|
||||
.dim-contextTooltip { position: absolute; top: calc(100% + 7px); left: 0; z-index: 30; width: min(330px, calc(100vw - 72px)); display: grid; gap: 5px; padding: 10px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); font-size: 11px; line-height: 16px; font-weight: 400; overflow-wrap: anywhere; white-space: normal; opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
|
||||
.dim-contextTooltip strong { font-weight: 600; }
|
||||
.dim-contextTooltipExample { padding: 7px 8px; border-radius: 6px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; }
|
||||
.dim-contextTooltip.dim-contextLegendTooltip { width: min(350px, calc(100vw - 72px)); }
|
||||
.dim-contextTooltip.dim-contextGuidanceTooltip { top: auto; bottom: calc(100% + 7px); width: min(380px, calc(100vw - 72px)); max-height: calc(100dvh - 48px); overflow-y: auto; }
|
||||
.dim-contextHelp:hover .dim-contextTooltip, .dim-contextHelp:focus-within .dim-contextTooltip { opacity: 1; visibility: visible; transform: translateY(0); pointer-events: auto; }
|
||||
.dim-contextTextActions { display: flex; gap: 10px; margin-left: auto; }
|
||||
.dim-contextTextActions button { min-height: 30px; padding: 4px 0; border: 0; border-radius: 4px; color: var(--dsw-alias-state-business-primary, #3370ff); background: transparent; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.dim-contextTextActions button:hover:not(:disabled) { text-decoration: underline; }
|
||||
.dim-contextGuidance textarea { display: block; width: 100%; min-height: 88px; margin-top: 7px; padding: 9px 10px; resize: vertical; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-module-platform, #f7f8fa); font: inherit; font-size: 12px; line-height: 1.6; }
|
||||
.dim-contextGuidance textarea::placeholder { color: var(--dsw-alias-label-tertiary, #8f959e); opacity: 1; }
|
||||
.dim-contextHint { margin: 5px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.dim-contextError { margin: 12px 0 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.dim-contextFooter { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
.dim-contextFooter button { min-height: 34px; padding: 6px 15px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; cursor: pointer; }
|
||||
.dim-contextFooter button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
|
||||
.dim-contextFooter .dim-contextSave, .dim-contextFooter .dim-contextSave:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #3370ff); color: #fff; background: var(--dsw-alias-state-business-primary, #3370ff); }
|
||||
.dim-contextEntry:focus-visible, .dim-contextDialog button:focus-visible, .dim-contextDialog input:focus-visible, .dim-contextDialog textarea:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
|
||||
.dim-contextEntry:disabled, .dim-contextDialog button:disabled, .dim-contextDialog input:disabled, .dim-contextDialog textarea:disabled { opacity: .55; cursor: not-allowed; }
|
||||
@media (pointer: coarse) {
|
||||
.dim-contextEntry, .dim-contextClose, .dim-contextFooter button, .dim-contextTextActions button, .dim-contextField, .dim-contextSwitchRow { min-height: 44px; }
|
||||
.dim-contextClose, .dim-contextTextActions button { min-width: 44px; }
|
||||
.dim-contextGuidance textarea { font-size: 16px; }
|
||||
}
|
||||
.dim-directoryPickerBackdrop { --dim-blue: var(--dsw-alias-state-business-primary, #3370ff); --dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent); position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgb(15 17 21 / 42%); backdrop-filter: blur(3px); }
|
||||
.dim-directoryPickerBackdrop, .dim-directoryPickerBackdrop *, .dim-directoryPickerBackdrop *::before, .dim-directoryPickerBackdrop *::after { box-sizing: border-box; }
|
||||
.dim-directoryPicker { width: min(720px, 100%); height: min(620px, calc(100vh - 48px)); min-height: 420px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 18px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 24px 72px rgb(15 17 21 / 24%); }
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
clientSecret,
|
||||
harness: workspaceScope.harness,
|
||||
state: workspaceScope.state,
|
||||
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
|
||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||
maxMessageChars: config.maxMessageChars ?? 4_000,
|
||||
connectTimeoutMs: config.connectTimeoutMs ?? 15_000,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import QRCode from 'qrcode';
|
||||
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
|
|
@ -18,6 +19,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
|
||||
approveSender: 'bot.sender.approve',
|
||||
revokeSender: 'bot.sender.revoke',
|
||||
});
|
||||
|
|
@ -94,6 +96,10 @@ function payloadFailure(endpoint, payload) {
|
|||
return validAgentPresetPayload(payload)
|
||||
? null : '请选择 Agent Preset。';
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.setContextEnhancement) {
|
||||
return validContextEnhancementPayload(payload)
|
||||
? null : '请提交有效的上下文增强设置。';
|
||||
}
|
||||
if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
|
||||
return exactKeys(payload, ['botId', 'requestId', 'confirm'])
|
||||
&& validId(payload.botId)
|
||||
|
|
@ -251,6 +257,11 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
|
|||
await controller.updateWorkspace(payload.botId, payload.workspace),
|
||||
cachedEncode,
|
||||
);
|
||||
} else if (endpoint === DINGTALK_ENDPOINTS.setContextEnhancement) {
|
||||
if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
|
||||
value = await controller.updateContextEnhancement(
|
||||
payload.botId, payload.config, (status) => publicStatus(status, cachedEncode),
|
||||
);
|
||||
} else if (endpoint === DINGTALK_ENDPOINTS.setAgentPreset) {
|
||||
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
|
||||
value = await publicStatus(
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
workspaces,
|
||||
method: 'removeBot',
|
||||
botIdFromRemoved: (removed) => removed.id,
|
||||
saveMethod: 'saveBot',
|
||||
botIdFromSave: (bot) => bot?.id,
|
||||
})
|
||||
: configStore;
|
||||
// State is lazy per bot. A corrupt legacy file can therefore fail only the
|
||||
|
|
@ -167,6 +169,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
ownerOpenIds: botConfig.ownerOpenIds ?? [botConfig.ownerOpenId],
|
||||
harness: workspaceScope.harness,
|
||||
state: workspaceScope.state,
|
||||
contextEnhancement: { botId: id, getSettings: () => workspaces.contextEnhancementFor(id) },
|
||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||
...(wsAgent ? { wsAgent } : {}),
|
||||
logger: {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { publicMessageFailure } from '../../../../src/channels/shared/message-fa
|
|||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
import { validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
||||
import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
|
||||
import {
|
||||
isFeishuGroupResponseMode,
|
||||
normalizeFeishuGroupResponseMode,
|
||||
|
|
@ -276,6 +278,7 @@ function publicBotEntry(entry) {
|
|||
connected,
|
||||
configured: source.configured === true,
|
||||
agentPreset: normalizeAgentPresetId(source.agentPreset),
|
||||
contextEnhancement: normalizeContextEnhancementConfig(source.contextEnhancement),
|
||||
groupResponseMode: normalizeFeishuGroupResponseMode(source.groupResponseMode),
|
||||
groupMessagePermissionGranted: source.groupMessagePermissionGranted === true,
|
||||
bot: publicBot(source.bot),
|
||||
|
|
@ -415,6 +418,10 @@ function validPayload(endpoint, payload) {
|
|||
return validAgentPresetPayload(payload)
|
||||
? null : '请选择 Agent Preset。';
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.setContextEnhancement) {
|
||||
return validContextEnhancementPayload(payload)
|
||||
? null : '请提交有效的上下文增强设置。';
|
||||
}
|
||||
if (endpoint === FEISHU_ENDPOINTS.setGroupResponseMode) {
|
||||
return hasOnlyKeys(payload, new Set(['botId', 'groupResponseMode']))
|
||||
&& safeOpaqueId(payload.botId)
|
||||
|
|
@ -666,6 +673,12 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
|||
await controller.updateWorkspace(payload.botId, payload.workspace),
|
||||
{ encodeQr: cachedEncodeQr },
|
||||
);
|
||||
} else if (endpoint === FEISHU_ENDPOINTS.setContextEnhancement) {
|
||||
if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
|
||||
value = await controller.updateContextEnhancement(
|
||||
payload.botId, payload.config,
|
||||
(status) => toPublicFeishuStatus(status, { encodeQr: cachedEncodeQr }),
|
||||
);
|
||||
} else if (endpoint === FEISHU_ENDPOINTS.setAgentPreset) {
|
||||
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
|
||||
value = await toPublicFeishuStatus(
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
appSecret,
|
||||
harness: workspaceScope.harness,
|
||||
state: workspaceScope.state,
|
||||
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
|
||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
|
||||
logger: {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
connectionTestTargetUnavailable,
|
||||
publicConnectionTestResult,
|
||||
} from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
|
|
@ -18,6 +19,7 @@ export const QQ_ENDPOINTS = Object.freeze({
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
|
||||
});
|
||||
export const QQ_RPC_ENDPOINTS = Object.freeze(Object.values(QQ_ENDPOINTS));
|
||||
|
||||
|
|
@ -76,6 +78,10 @@ function payloadFailure(endpoint, payload) {
|
|||
return validAgentPresetPayload(payload)
|
||||
? null : '请选择 Agent Preset。';
|
||||
}
|
||||
if (endpoint === QQ_ENDPOINTS.setContextEnhancement) {
|
||||
return validContextEnhancementPayload(payload)
|
||||
? null : '请提交有效的上下文增强设置。';
|
||||
}
|
||||
return 'Unknown QQ endpoint.';
|
||||
}
|
||||
|
||||
|
|
@ -171,6 +177,11 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
|||
await controller.updateWorkspace(payload.botId, payload.workspace),
|
||||
cachedEncode,
|
||||
);
|
||||
} else if (endpoint === QQ_ENDPOINTS.setContextEnhancement) {
|
||||
if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
|
||||
value = await controller.updateContextEnhancement(
|
||||
payload.botId, payload.config, (status) => publicStatus(status, cachedEncode),
|
||||
);
|
||||
} else if (endpoint === QQ_ENDPOINTS.setAgentPreset) {
|
||||
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
|
||||
value = await publicStatus(
|
||||
|
|
|
|||
17
plugin-src/host/channels/shared/context-enhancement-rpc.mjs
Normal file
17
plugin-src/host/channels/shared/context-enhancement-rpc.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { validateContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
export const SET_CONTEXT_ENHANCEMENT_ENDPOINT = 'bot.context-enhancement.set';
|
||||
|
||||
export function validContextEnhancementPayload(payload) {
|
||||
try {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)
|
||||
|| Reflect.ownKeys(payload).length !== 2
|
||||
|| !Object.hasOwn(payload, 'botId') || !Object.hasOwn(payload, 'config')
|
||||
|| typeof payload.botId !== 'string'
|
||||
|| !/^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)) return false;
|
||||
validateContextEnhancementConfig(payload.config);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -102,6 +102,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|||
token,
|
||||
harness: workspaceScope.harness,
|
||||
state: workspaceScope.state,
|
||||
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
|
||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
|
||||
logger: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from './context-enhancement-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
|
|
@ -17,6 +18,7 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
|
||||
});
|
||||
|
||||
const ENDPOINTS = Object.freeze(Object.values(TOKEN_BOT_ENDPOINTS));
|
||||
|
|
@ -71,6 +73,10 @@ function payloadFailure(endpoint, payload) {
|
|||
return validAgentPresetPayload(payload)
|
||||
? null : '请选择 Agent Preset。';
|
||||
}
|
||||
if (endpoint === TOKEN_BOT_ENDPOINTS.setContextEnhancement) {
|
||||
return validContextEnhancementPayload(payload)
|
||||
? null : '请提交有效的上下文增强设置。';
|
||||
}
|
||||
return 'Unknown bot endpoint.';
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +158,9 @@ export function createTokenBotRpcHandler(controller, { channel }) {
|
|||
} else if (endpoint === TOKEN_BOT_ENDPOINTS.setWorkspace) {
|
||||
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
|
||||
value = await controller.updateWorkspace(payload.botId, payload.workspace);
|
||||
} else if (endpoint === TOKEN_BOT_ENDPOINTS.setContextEnhancement) {
|
||||
if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
|
||||
value = await controller.updateContextEnhancement(payload.botId, payload.config);
|
||||
} else if (endpoint === TOKEN_BOT_ENDPOINTS.setAgentPreset) {
|
||||
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
|
||||
value = await controller.updateAgentPreset(payload.botId, payload.agentPreset);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export function publicWorkspaceError(error) {
|
|||
'workspace-bot-not-found',
|
||||
'agent-preset-invalid',
|
||||
'agent-preset-unavailable',
|
||||
'context-enhancement-invalid',
|
||||
].includes(error?.code)) return null;
|
||||
return { code: error.code, message: error.message };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
appToken,
|
||||
harness: workspaceScope.harness,
|
||||
state: workspaceScope.state,
|
||||
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
|
||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
|
||||
logger: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import {
|
||||
|
|
@ -18,6 +19,7 @@ export const SLACK_ENDPOINTS = Object.freeze({
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
|
||||
});
|
||||
export const SLACK_RPC_ENDPOINTS = Object.freeze(Object.values(SLACK_ENDPOINTS));
|
||||
|
||||
|
|
@ -75,6 +77,10 @@ function payloadFailure(endpoint, payload) {
|
|||
return validAgentPresetPayload(payload)
|
||||
? null : '请选择 Agent Preset。';
|
||||
}
|
||||
if (endpoint === SLACK_ENDPOINTS.setContextEnhancement) {
|
||||
return validContextEnhancementPayload(payload)
|
||||
? null : '请提交有效的上下文增强设置。';
|
||||
}
|
||||
return 'Unknown Slack endpoint.';
|
||||
}
|
||||
|
||||
|
|
@ -154,6 +160,10 @@ export function createSlackRpcHandler(controller) {
|
|||
if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
|
||||
value = await controller.updateWorkspace(payload.botId, payload.workspace);
|
||||
}
|
||||
else if (endpoint === SLACK_ENDPOINTS.setContextEnhancement) {
|
||||
if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
|
||||
value = await controller.updateContextEnhancement(payload.botId, payload.config);
|
||||
}
|
||||
else if (endpoint === SLACK_ENDPOINTS.setAgentPreset) {
|
||||
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
|
||||
value = await controller.updateAgentPreset(payload.botId, payload.agentPreset);
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
secret,
|
||||
harness: workspaceScope.harness,
|
||||
state: workspaceScope.state,
|
||||
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
|
||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
|
||||
maxReconnectAttempts: config.maxReconnectAttempts ?? 10,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import QRCode from 'qrcode';
|
||||
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
|
|
@ -18,6 +19,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
|
||||
});
|
||||
export const WECOM_RPC_ENDPOINTS = Object.freeze(Object.values(WECOM_ENDPOINTS));
|
||||
|
||||
|
|
@ -76,6 +78,10 @@ function payloadFailure(endpoint, payload) {
|
|||
return validAgentPresetPayload(payload)
|
||||
? null : '请选择 Agent Preset。';
|
||||
}
|
||||
if (endpoint === WECOM_ENDPOINTS.setContextEnhancement) {
|
||||
return validContextEnhancementPayload(payload)
|
||||
? null : '请提交有效的上下文增强设置。';
|
||||
}
|
||||
return 'Unknown Enterprise WeChat endpoint.';
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +178,11 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {})
|
|||
await controller.updateWorkspace(payload.botId, payload.workspace),
|
||||
cachedEncode,
|
||||
);
|
||||
} else if (endpoint === WECOM_ENDPOINTS.setContextEnhancement) {
|
||||
if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
|
||||
value = await controller.updateContextEnhancement(
|
||||
payload.botId, payload.config, (status) => publicStatus(status, cachedEncode),
|
||||
);
|
||||
} else if (endpoint === WECOM_ENDPOINTS.setAgentPreset) {
|
||||
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
|
||||
value = await publicStatus(
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
token,
|
||||
harness: workspaceScope.harness,
|
||||
state: workspaceScope.state,
|
||||
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
|
||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||
maxMessageChars: config.maxMessageChars ?? DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
|
||||
logger: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import QRCode from 'qrcode';
|
||||
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import {
|
||||
publicWorkspaceError,
|
||||
|
|
@ -25,6 +26,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
|
||||
});
|
||||
export const WEIXIN_RPC_ENDPOINTS = Object.freeze(Object.values(WEIXIN_ENDPOINTS));
|
||||
|
||||
|
|
@ -83,6 +85,10 @@ function payloadFailure(endpoint, payload) {
|
|||
return validAgentPresetPayload(payload)
|
||||
? null : '请选择 Agent Preset。';
|
||||
}
|
||||
if (endpoint === WEIXIN_ENDPOINTS.setContextEnhancement) {
|
||||
return validContextEnhancementPayload(payload)
|
||||
? null : '请提交有效的上下文增强设置。';
|
||||
}
|
||||
return 'Unknown Weixin endpoint.';
|
||||
}
|
||||
|
||||
|
|
@ -207,6 +213,11 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
|
|||
await controller.updateWorkspace(payload.botId, payload.workspace),
|
||||
cachedEncode,
|
||||
);
|
||||
} else if (endpoint === WEIXIN_ENDPOINTS.setContextEnhancement) {
|
||||
if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
|
||||
value = await controller.updateContextEnhancement(
|
||||
payload.botId, payload.config, (status) => publicStatus(status, cachedEncode),
|
||||
);
|
||||
} else if (endpoint === WEIXIN_ENDPOINTS.setAgentPreset) {
|
||||
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
|
||||
value = await publicStatus(
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|||
authDir,
|
||||
harness: workspaceScope.harness,
|
||||
state: workspaceScope.state,
|
||||
contextEnhancement: { botId, getSettings: () => workspaces.contextEnhancementFor(botId) },
|
||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||
connectTimeoutMs: config.connectTimeoutMs ?? 30_000,
|
||||
createSession,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import QRCode from 'qrcode';
|
|||
|
||||
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
|
||||
import { normalizeWhatsappAccessPolicy } from '../../../../src/channels/whatsapp/config-store.mjs';
|
||||
import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
|
||||
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
|
||||
|
|
@ -17,6 +18,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
|
|||
setAccessPolicy: 'bot.access-policy.set',
|
||||
setWorkspace: SET_WORKSPACE_ENDPOINT,
|
||||
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
|
||||
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
|
||||
});
|
||||
export const WHATSAPP_RPC_ENDPOINTS = Object.freeze(Object.values(WHATSAPP_ENDPOINTS));
|
||||
|
||||
|
|
@ -71,6 +73,10 @@ function payloadFailure(endpoint, payload) {
|
|||
return validAgentPresetPayload(payload)
|
||||
? null : '请选择 Agent Preset。';
|
||||
}
|
||||
if (endpoint === WHATSAPP_ENDPOINTS.setContextEnhancement) {
|
||||
return validContextEnhancementPayload(payload)
|
||||
? null : '请提交有效的上下文增强设置。';
|
||||
}
|
||||
return 'Unknown WhatsApp endpoint.';
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +179,11 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
|
|||
await controller.updateWorkspace(payload.botId, payload.workspace),
|
||||
cachedEncode,
|
||||
);
|
||||
} else if (endpoint === WHATSAPP_ENDPOINTS.setContextEnhancement) {
|
||||
if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
|
||||
value = await controller.updateContextEnhancement(
|
||||
payload.botId, payload.config, (status) => publicStatus(status, cachedEncode),
|
||||
);
|
||||
} else if (endpoint === WHATSAPP_ENDPOINTS.setAgentPreset) {
|
||||
if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent preset update is unavailable');
|
||||
value = await publicStatus(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const required = [
|
|||
'plugin-src/client/channels/slack/index.js',
|
||||
'plugin-src/client/i18n.js',
|
||||
'plugin-src/client/update-panel.js',
|
||||
'plugin-src/client/context-enhancement.js',
|
||||
'plugin-src/host/update-service.mjs',
|
||||
'plugin-src/host/update-runtime.mjs',
|
||||
'plugin-src/host/update-rpc.mjs',
|
||||
|
|
@ -51,6 +52,7 @@ const required = [
|
|||
'src/channels/discord/discord-runtime.mjs',
|
||||
'src/channels/whatsapp/whatsapp-runtime.mjs',
|
||||
'src/channels/whatsapp/whatsapp-web-session.mjs',
|
||||
'src/channels/shared/context-enhancement.mjs',
|
||||
];
|
||||
await Promise.all(required.map((path) => access(resolve(root, path))));
|
||||
|
||||
|
|
@ -135,8 +137,19 @@ if ((client.match(/\.slots\.inject\(\s*["']settings\.section["']/gu) ?? []).leng
|
|||
if (client.includes('settings.plugins.tab') || clientSources.includes('settings.plugins.tab')) {
|
||||
throw new Error('client source or bundle still contains the legacy Plugins-tab settings entry');
|
||||
}
|
||||
if (/role:\s*["']switch|type:\s*["']checkbox/.test(client)) {
|
||||
throw new Error('client bundle contains a channel enable switch');
|
||||
// Connections still have no channel-enable toggle. Only the shared context
|
||||
// editor owns checkable inputs: two scope switches and one mapped field input.
|
||||
const contextEditorSource = await readFile(resolve(root, 'plugin-src/client/context-enhancement.js'), 'utf8');
|
||||
const otherClientSources = clientSources.replace(contextEditorSource, '');
|
||||
if (/role:\s*["']switch|type:\s*["']checkbox/.test(otherClientSources)
|
||||
|| (client.match(/role:\s*["']switch["']/g) ?? []).length !== 2
|
||||
|| (client.match(/type:\s*["']checkbox["']/g) ?? []).length !== 3) {
|
||||
throw new Error('checkable inputs must be limited to the context-enhancement editor');
|
||||
}
|
||||
for (const marker of ['bot.context-enhancement.set', '<dsh_im_source>', '<dsh_im_source_guidance>']) {
|
||||
if (!host.includes(marker) || !client.includes(marker)) {
|
||||
throw new Error(`context-enhancement marker missing from Host or Client bundle: ${marker}`);
|
||||
}
|
||||
}
|
||||
if (!client.includes('container-type: inline-size')
|
||||
|| !client.includes('@container (max-width: 680px)')) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
|
||||
import {
|
||||
BatchInputManager,
|
||||
batchInputBusyMessage,
|
||||
|
|
@ -372,6 +373,7 @@ export class DingtalkHarnessBridge {
|
|||
#clientSecret;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#status;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
|
|
@ -383,7 +385,8 @@ export class DingtalkHarnessBridge {
|
|||
#interactionKeys = new Map();
|
||||
#interactionTasks = new Set();
|
||||
#commandTasks = new Set();
|
||||
#acceptedMessageIds = new Set();
|
||||
// Keep the accepted configuration through the existing queue/reply lifecycle.
|
||||
#acceptedMessageIds = new Map();
|
||||
#approvals;
|
||||
#batchInputs = new BatchInputManager();
|
||||
|
||||
|
|
@ -393,6 +396,7 @@ export class DingtalkHarnessBridge {
|
|||
clientSecret,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
status = createDingtalkBridgeStatus(),
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
|
|
@ -410,6 +414,7 @@ export class DingtalkHarnessBridge {
|
|||
this.#clientSecret = clientSecret.trim();
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#status = status;
|
||||
this.#logger = logger;
|
||||
this.#approvals = new HarnessApprovalQueue({ label: 'DingTalk', logger });
|
||||
|
|
@ -428,13 +433,17 @@ export class DingtalkHarnessBridge {
|
|||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
accept(message) {
|
||||
accept(message, { contextSnapshot } = {}) {
|
||||
if (this.#signal?.aborted) return Promise.resolve();
|
||||
const messageId = nonEmptyString(message?.msgId);
|
||||
const sender = senderStaffId(message);
|
||||
if (!messageId || !sender || this.#state.hasSeen(messageId)
|
||||
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
||||
this.#acceptedMessageIds.add(messageId);
|
||||
this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined ? captureContextEnhancement(
|
||||
this.#contextEnhancement,
|
||||
message.conversationType === '1' || message.conversationType === 1 ? 'direct'
|
||||
: message.conversationType === '2' || message.conversationType === 2 ? 'group' : null,
|
||||
) : contextSnapshot);
|
||||
|
||||
let key;
|
||||
try {
|
||||
|
|
@ -931,9 +940,17 @@ export class DingtalkHarnessBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
const content = hasImages
|
||||
let content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
channel: 'dingtalk',
|
||||
senderId: sender,
|
||||
senderName: message.senderNick,
|
||||
}));
|
||||
}
|
||||
if (typeof this.#api.createAiCard === 'function'
|
||||
&& typeof this.#api.updateAiCard === 'function'
|
||||
&& typeof this.#api.finishAiCard === 'function') {
|
||||
|
|
@ -951,7 +968,7 @@ export class DingtalkHarnessBridge {
|
|||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
...(hasImages ? { content } : { text }),
|
||||
...(content !== undefined ? { content } : { text }),
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
} from './dingtalk-bridge.mjs';
|
||||
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
|
||||
|
||||
function nonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -128,6 +129,7 @@ export class DingtalkRuntime {
|
|||
#clientSecret;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#maxMessageChars;
|
||||
|
|
@ -149,6 +151,7 @@ export class DingtalkRuntime {
|
|||
clientSecret,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
maxMessageChars = 4_000,
|
||||
|
|
@ -166,6 +169,7 @@ export class DingtalkRuntime {
|
|||
this.#clientSecret = clientSecret.trim();
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#maxMessageChars = maxMessageChars;
|
||||
|
|
@ -233,6 +237,7 @@ export class DingtalkRuntime {
|
|||
approvedSenders: this.#config.approvedSenders,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
contextEnhancement: this.#contextEnhancement,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
|
|
@ -268,6 +273,15 @@ export class DingtalkRuntime {
|
|||
}
|
||||
}
|
||||
|
||||
// Retain the committed, read-only settings at receipt without moving
|
||||
// JSON parsing out of the existing asynchronous callback path.
|
||||
const contextEnhancement = this.#contextEnhancement;
|
||||
let receivedSettings;
|
||||
try {
|
||||
receivedSettings = contextEnhancement?.getSettings?.();
|
||||
} catch {
|
||||
// Optional settings failures leave the original message path active.
|
||||
}
|
||||
const task = Promise.resolve().then(async () => {
|
||||
if (this.#bridge !== bridge) return;
|
||||
let message;
|
||||
|
|
@ -282,7 +296,12 @@ export class DingtalkRuntime {
|
|||
}
|
||||
if (!message || typeof message !== 'object') return;
|
||||
this.#status.lastCallbackAt = Date.now();
|
||||
await bridge.accept(message);
|
||||
const contextSnapshot = captureContextEnhancement({
|
||||
getSettings: () => receivedSettings,
|
||||
get botId() { return contextEnhancement?.botId; },
|
||||
}, message.conversationType === '1' || message.conversationType === 1 ? 'direct'
|
||||
: message.conversationType === '2' || message.conversationType === 2 ? 'group' : null);
|
||||
await bridge.accept(message, { contextSnapshot });
|
||||
}).catch(() => {
|
||||
if (signal.aborted || this.#bridge !== bridge) return;
|
||||
this.#status.lastError = t('钉钉消息处理失败。');
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { createEditableMessageStream, splitMessageText } from '../shared/editabl
|
|||
import { fetchFileStream } from '../shared/file-download.mjs';
|
||||
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
|
||||
import { DiscordApi } from './discord-api.mjs';
|
||||
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
|
||||
|
||||
|
|
@ -213,6 +214,10 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
|
|||
return {
|
||||
messageId: String(message.id),
|
||||
senderId: String(message.author.id),
|
||||
contextSource: () => ({
|
||||
senderName: [message.member?.nick, message.author.global_name, message.author.username]
|
||||
.find((value) => typeof value === 'string' && value.trim()),
|
||||
}),
|
||||
senderIsBot: message.author.bot === true,
|
||||
kind: direct ? 'direct' : 'group',
|
||||
conversationId: String(message.channel_id),
|
||||
|
|
@ -427,6 +432,7 @@ export class DiscordRuntime {
|
|||
#token;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#connectTimeoutMs;
|
||||
|
|
@ -457,6 +463,7 @@ export class DiscordRuntime {
|
|||
token,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
connectTimeoutMs = 20_000,
|
||||
|
|
@ -472,6 +479,7 @@ export class DiscordRuntime {
|
|||
this.#token = token;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
|
|
@ -534,6 +542,7 @@ export class DiscordRuntime {
|
|||
bot: client,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
contextEnhancement: this.#contextEnhancement,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
|
|
@ -707,20 +716,25 @@ export class DiscordRuntime {
|
|||
if (!messageId || this.#state.hasSeen(messageId)) return;
|
||||
let route = this.#routing.get(messageId);
|
||||
if (!route) {
|
||||
route = resolveDiscordMessageRoute(message, this.#config.platformId, {
|
||||
const contextSnapshot = captureContextEnhancement(
|
||||
this.#contextEnhancement,
|
||||
message.guild_id ? 'group' : 'direct',
|
||||
);
|
||||
const pendingRoute = resolveDiscordMessageRoute(message, this.#config.platformId, {
|
||||
api: this.#api,
|
||||
channel: this.#channels.get(String(message.channel_id)),
|
||||
signal: this.#abortController?.signal,
|
||||
onChannel: (resolved) => this.#rememberChannel(resolved),
|
||||
});
|
||||
route = { pendingRoute, contextSnapshot };
|
||||
this.#routing.set(messageId, route);
|
||||
void route.finally(() => {
|
||||
void pendingRoute.finally(() => {
|
||||
if (this.#routing.get(messageId) === route) this.#routing.delete(messageId);
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
const normalized = await route;
|
||||
if (normalized) await bridge.accept(normalized);
|
||||
const normalized = await route.pendingRoute;
|
||||
if (normalized) await bridge.accept(normalized, { contextSnapshot: route.contextSnapshot });
|
||||
} catch (error) {
|
||||
if (error?.code === 'discord-thread-create-uncertain') {
|
||||
await this.#state.markSeen(messageId);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
|
||||
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
|
||||
import {
|
||||
createDeliveryReceipt,
|
||||
|
|
@ -383,12 +384,14 @@ export class FeishuHarnessBridge {
|
|||
#channel;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#queues = new Map();
|
||||
#batchInputs = new BatchInputManager();
|
||||
#pendingInteractions = new Map();
|
||||
#interactionKeys = new Map();
|
||||
#resolvedQuestionReplies = new Map();
|
||||
#acceptedMessageIds = new Set();
|
||||
// Keep the accepted configuration through the existing queue/reply lifecycle.
|
||||
#acceptedMessageIds = new Map();
|
||||
#interactionTasks = new Set();
|
||||
#commandTasks = new Set();
|
||||
/** All accepted card work, including tasks waiting behind an earlier click. */
|
||||
|
|
@ -447,6 +450,7 @@ export class FeishuHarnessBridge {
|
|||
channel,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
status,
|
||||
allowedSenderOpenIds = new Set(),
|
||||
botId,
|
||||
|
|
@ -483,6 +487,7 @@ export class FeishuHarnessBridge {
|
|||
this.#channel = channel;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#status = status;
|
||||
this.#allowedSenderOpenIds = allowedSenderOpenIds;
|
||||
this.#botId = nonEmptyString(botId);
|
||||
|
|
@ -551,7 +556,10 @@ export class FeishuHarnessBridge {
|
|||
if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
|
||||
}
|
||||
|
||||
this.#acceptedMessageIds.add(messageId);
|
||||
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
|
||||
this.#contextEnhancement,
|
||||
event.message.chat_type === 'p2p' ? 'direct' : event.message.chat_type === 'group' ? 'group' : null,
|
||||
));
|
||||
const processingReaction = this.#beginReaction(messageId);
|
||||
const commandMessage = extractInboundMessage(event, this.#client);
|
||||
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
||||
|
|
@ -3143,9 +3151,16 @@ export class FeishuHarnessBridge {
|
|||
askCompleted = true;
|
||||
onAskComplete?.();
|
||||
};
|
||||
const content = hasInboundImages(message)
|
||||
let content = hasInboundImages(message)
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
channel: 'feishu',
|
||||
senderId: senderOpenId(event),
|
||||
}));
|
||||
}
|
||||
if (!this.#channel?.stream) {
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export class FeishuRuntime {
|
|||
#ownerOpenIds;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#replyTimeoutMs;
|
||||
#connectTimeoutMs;
|
||||
#requestTimeoutMs;
|
||||
|
|
@ -131,6 +132,7 @@ export class FeishuRuntime {
|
|||
ownerOpenIds,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
repair,
|
||||
replyTimeoutMs = 600000,
|
||||
connectTimeoutMs = 15000,
|
||||
|
|
@ -162,6 +164,7 @@ export class FeishuRuntime {
|
|||
this.#ownerOpenIds = normalizedOwners;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#repair = repair ?? null;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
|
|
@ -260,6 +263,7 @@ export class FeishuRuntime {
|
|||
channel,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
contextEnhancement: this.#contextEnhancement,
|
||||
status: this.#status,
|
||||
allowedSenderOpenIds: new Set(this.#ownerOpenIds),
|
||||
botId: this.#botId,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
runPresetCommand,
|
||||
} from '../shared/preset-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
|
||||
import {
|
||||
BatchInputManager,
|
||||
batchInputBusyMessage,
|
||||
|
|
@ -365,6 +366,7 @@ export class QqHarnessBridge {
|
|||
#ownerUserOpenid;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#status;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
|
|
@ -374,7 +376,8 @@ export class QqHarnessBridge {
|
|||
#queues = new Map();
|
||||
#pendingInteractions = new Map();
|
||||
#interactionKeys = new Map();
|
||||
#acceptedMessageIds = new Set();
|
||||
// Keep the accepted configuration through the existing queue/reply lifecycle.
|
||||
#acceptedMessageIds = new Map();
|
||||
#approvalTasks = new Set();
|
||||
#commandTasks = new Set();
|
||||
#approvals;
|
||||
|
|
@ -385,6 +388,7 @@ export class QqHarnessBridge {
|
|||
ownerUserOpenid,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
status = createQqBridgeStatus(),
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
|
|
@ -403,6 +407,7 @@ export class QqHarnessBridge {
|
|||
this.#ownerUserOpenid = ownerUserOpenid;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#status = status;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
|
|
@ -425,7 +430,10 @@ export class QqHarnessBridge {
|
|||
|| this.#state.hasSeen(messageId)
|
||||
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
||||
const key = conversationKey(message);
|
||||
this.#acceptedMessageIds.add(messageId);
|
||||
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
|
||||
this.#contextEnhancement,
|
||||
message.kind === 'c2c' ? 'direct' : 'group',
|
||||
));
|
||||
if (message.kind === 'c2c'
|
||||
&& (this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid)
|
||||
&& message.replyTarget?.scope === 'c2c'
|
||||
|
|
@ -777,9 +785,17 @@ export class QqHarnessBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
const content = hasImages
|
||||
let content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
channel: 'qq',
|
||||
senderId: sender,
|
||||
senderName: message.kind === 'group' ? message.senderName : undefined,
|
||||
}));
|
||||
}
|
||||
// QQ stream_messages can acknowledge a final frame without rendering it in
|
||||
// some C2C clients. Standard Markdown delivery is the reliable reply path.
|
||||
const toolErrors = [];
|
||||
|
|
@ -793,7 +809,7 @@ export class QqHarnessBridge {
|
|||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
...(hasImages ? { content } : { text }),
|
||||
...(content !== undefined ? { content } : { text }),
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export class QqRuntime {
|
|||
#appSecret;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#connectTimeoutMs;
|
||||
|
|
@ -48,6 +49,7 @@ export class QqRuntime {
|
|||
appSecret,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
connectTimeoutMs = 20_000,
|
||||
|
|
@ -61,6 +63,7 @@ export class QqRuntime {
|
|||
this.#appSecret = appSecret;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
|
|
@ -139,6 +142,7 @@ export class QqRuntime {
|
|||
ownerUserOpenid: this.#config.ownerUserOpenid,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
contextEnhancement: this.#contextEnhancement,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ import {
|
|||
validateAgentPresetId,
|
||||
} from './agent-preset.mjs';
|
||||
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
|
||||
import {
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
normalizeContextEnhancementConfig,
|
||||
validateContextEnhancementConfig,
|
||||
} from './context-enhancement.mjs';
|
||||
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
|
||||
|
||||
const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
|
||||
|
|
@ -68,7 +73,17 @@ function normalizeDocument(value) {
|
|||
}
|
||||
}
|
||||
}
|
||||
return { version: 1, workspaces, agentPresets };
|
||||
const contextEnhancement = Object.create(null);
|
||||
// Enhancement damage is isolated from the existing workspace/preset document.
|
||||
if (value.contextEnhancement && typeof value.contextEnhancement === 'object'
|
||||
&& !Array.isArray(value.contextEnhancement)) {
|
||||
for (const [botId, config] of Object.entries(value.contextEnhancement)) {
|
||||
if (/^[A-Za-z0-9_-]{1,128}$/.test(botId)) {
|
||||
contextEnhancement[botId] = normalizeContextEnhancementConfig(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { version: 1, workspaces, agentPresets, contextEnhancement };
|
||||
}
|
||||
|
||||
export async function validateWorkspacePath(value) {
|
||||
|
|
@ -99,6 +114,7 @@ export class BotWorkspaceStore {
|
|||
#defaultWorkspace;
|
||||
#workspaces = {};
|
||||
#agentPresets = {};
|
||||
#contextEnhancement = {};
|
||||
#generations = new Map();
|
||||
#nextGeneration = 1;
|
||||
#incarnations = new Map();
|
||||
|
|
@ -121,10 +137,12 @@ export class BotWorkspaceStore {
|
|||
if (!normalized) throw new Error('dsh-im workspace config is invalid');
|
||||
this.#workspaces = normalized.workspaces;
|
||||
this.#agentPresets = normalized.agentPresets;
|
||||
this.#contextEnhancement = normalized.contextEnhancement;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
this.#workspaces = {};
|
||||
this.#agentPresets = {};
|
||||
this.#contextEnhancement = {};
|
||||
}
|
||||
this.#generations.clear();
|
||||
this.#nextGeneration = 1;
|
||||
|
|
@ -156,6 +174,13 @@ export class BotWorkspaceStore {
|
|||
return this.#agentPresets[botIdOf(botId)] ?? null;
|
||||
}
|
||||
|
||||
contextEnhancementFor(botId) {
|
||||
const id = botIdOf(botId);
|
||||
return this.has(id) && Object.hasOwn(this.#contextEnhancement, id)
|
||||
? this.#contextEnhancement[id]
|
||||
: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
|
||||
}
|
||||
|
||||
generationFor(botId) {
|
||||
return this.#generations.get(botIdOf(botId)) ?? null;
|
||||
}
|
||||
|
|
@ -270,6 +295,24 @@ export class BotWorkspaceStore {
|
|||
});
|
||||
}
|
||||
|
||||
async setContextEnhancement(botId, value, { incarnation } = {}) {
|
||||
const id = botIdOf(botId);
|
||||
const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
|
||||
const config = validateContextEnhancementConfig(value);
|
||||
return this.#enqueue(id, async () => {
|
||||
if (!this.has(id) || expectedIncarnation !== this.incarnationFor(id)) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
const next = { ...this.#contextEnhancement, [id]: config };
|
||||
// Messages keep the previous committed snapshot until rename succeeds.
|
||||
await this.#persist(next);
|
||||
this.#contextEnhancement = next;
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
async bindWorkspaceSession(botId, value, {
|
||||
conversationKey,
|
||||
sessionId,
|
||||
|
|
@ -408,6 +451,14 @@ export class BotWorkspaceStore {
|
|||
});
|
||||
}
|
||||
|
||||
/** A failed retirement must reach disk before the same config ID can be rebound. */
|
||||
flushPendingRemoval(botId) {
|
||||
if (!this.#dirtyRemovals.has(botId)) return undefined;
|
||||
return this.#enqueue(botId, async () => {
|
||||
if (this.#dirtyRemovals.has(botId)) await this.#persistCurrentDocument();
|
||||
});
|
||||
}
|
||||
|
||||
async remove(botId) {
|
||||
const result = await this.retireAfterConfigCommit(botId);
|
||||
if (result.error) throw result.error;
|
||||
|
|
@ -419,6 +470,7 @@ export class BotWorkspaceStore {
|
|||
const candidates = new Set([
|
||||
...Object.keys(this.#workspaces),
|
||||
...Object.keys(this.#agentPresets),
|
||||
...Object.keys(this.#contextEnhancement),
|
||||
...this.#dirtyRemovals,
|
||||
]);
|
||||
for (const botId of candidates) {
|
||||
|
|
@ -435,6 +487,7 @@ export class BotWorkspaceStore {
|
|||
...bot,
|
||||
workspace: this.workspaceFor(bot.botId),
|
||||
agentPreset: this.agentPresetFor(bot.botId),
|
||||
contextEnhancement: this.contextEnhancementFor(bot.botId),
|
||||
}
|
||||
: bot),
|
||||
};
|
||||
|
|
@ -464,9 +517,11 @@ export class BotWorkspaceStore {
|
|||
async #retireCurrentIncarnation(id) {
|
||||
const hadWorkspace = Object.hasOwn(this.#workspaces, id);
|
||||
const hadPreset = Object.hasOwn(this.#agentPresets, id);
|
||||
const needsCleanup = hadWorkspace || hadPreset || this.#dirtyRemovals.has(id);
|
||||
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
|
||||
const needsCleanup = hadWorkspace || hadPreset || hadContextEnhancement || this.#dirtyRemovals.has(id);
|
||||
delete this.#workspaces[id];
|
||||
delete this.#agentPresets[id];
|
||||
delete this.#contextEnhancement[id];
|
||||
this.#generations.delete(id);
|
||||
this.#incarnations.delete(id);
|
||||
if (!needsCleanup) return {
|
||||
|
|
@ -496,11 +551,14 @@ export class BotWorkspaceStore {
|
|||
return queued;
|
||||
}
|
||||
|
||||
async #persist() {
|
||||
async #persist(contextEnhancement = this.#contextEnhancement) {
|
||||
const document = { version: 1, workspaces: this.#workspaces };
|
||||
if (Object.keys(this.#agentPresets).length > 0) {
|
||||
document.agentPresets = this.#agentPresets;
|
||||
}
|
||||
if (Object.keys(contextEnhancement).length > 0) {
|
||||
document.contextEnhancement = contextEnhancement;
|
||||
}
|
||||
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.#path}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
||||
|
|
@ -513,7 +571,8 @@ export class BotWorkspaceStore {
|
|||
|
||||
async #persistCurrentDocument() {
|
||||
if (Object.keys(this.#workspaces).length > 0
|
||||
|| Object.keys(this.#agentPresets).length > 0) {
|
||||
|| Object.keys(this.#agentPresets).length > 0
|
||||
|| Object.keys(this.#contextEnhancement).length > 0) {
|
||||
await this.#persist();
|
||||
return;
|
||||
}
|
||||
|
|
@ -569,10 +628,16 @@ function targetStatus(controller) {
|
|||
return Promise.resolve(controller.status());
|
||||
}
|
||||
|
||||
/** Observe the config store's durable removal commit without changing its API. */
|
||||
/** Observe durable removals and finish failed cleanup before a same-ID config save. */
|
||||
export function observeBotWorkspaceRemovals(
|
||||
configStore,
|
||||
{ workspaces, method = 'remove', botIdFromRemoved = (removed) => removed?.botId },
|
||||
{
|
||||
workspaces,
|
||||
method = 'remove',
|
||||
botIdFromRemoved = (removed) => removed?.botId,
|
||||
saveMethod = 'save',
|
||||
botIdFromSave = (config) => config?.botId,
|
||||
},
|
||||
) {
|
||||
if (!configStore || !workspaces || typeof configStore[method] !== 'function') {
|
||||
throw new TypeError('configStore removal observer dependencies are required');
|
||||
|
|
@ -588,6 +653,12 @@ export function observeBotWorkspaceRemovals(
|
|||
return removed;
|
||||
};
|
||||
}
|
||||
if (property === saveMethod && typeof value === 'function') {
|
||||
return (...args) => {
|
||||
const cleanup = workspaces.flushPendingRemoval(botIdFromSave(args[0], args));
|
||||
return cleanup ? cleanup.then(() => value.apply(target, args)) : value.apply(target, args);
|
||||
};
|
||||
}
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
|
|
@ -998,6 +1069,31 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
|
|||
);
|
||||
});
|
||||
};
|
||||
const updateContextEnhancement = (botId, value, projectStatus) => {
|
||||
const incarnation = workspaces.incarnationFor(botId);
|
||||
const config = validateContextEnhancementConfig(value);
|
||||
return withBotTransition(botId, async () => {
|
||||
const snapshot = await controller.status();
|
||||
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
const catalog = await resolveAgentPresetCatalog(agentPresetCatalog);
|
||||
const decorated = workspaces.decorateStatus(snapshot);
|
||||
const updated = {
|
||||
...decorated,
|
||||
bots: decorated.bots.map((bot) => bot?.botId === botId
|
||||
? { ...bot, contextEnhancement: config } : bot),
|
||||
...(catalog ? { agentPresetCatalog: catalog } : {}),
|
||||
};
|
||||
// QR/status projection can fail too. Prepare the complete response before
|
||||
// commit so a failed save never publishes new running settings.
|
||||
const result = projectStatus ? await projectStatus(updated) : updated;
|
||||
await workspaces.setContextEnhancement(botId, config, { incarnation });
|
||||
return result;
|
||||
});
|
||||
};
|
||||
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
|
||||
// Fence the old runtime without changing the durable mapping. A crash
|
||||
// before the controller removes its config therefore keeps the bot's
|
||||
|
|
@ -1037,6 +1133,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
|
|||
get(target, property) {
|
||||
if (property === 'updateWorkspace') return updateWorkspace;
|
||||
if (property === 'updateAgentPreset') return updateAgentPreset;
|
||||
if (property === 'updateContextEnhancement') return updateContextEnhancement;
|
||||
const value = Reflect.get(target, property, target);
|
||||
if (typeof value !== 'function') return value;
|
||||
if (property === 'deleteBot') {
|
||||
|
|
|
|||
135
src/channels/shared/context-enhancement.mjs
Normal file
135
src/channels/shared/context-enhancement.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Shared by the Host and settings UI; keep this module browser-compatible.
|
||||
export const CONTEXT_ENHANCEMENT_FIELDS = Object.freeze([
|
||||
'channel', 'conversationType', 'senderId', 'senderName', 'botId',
|
||||
]);
|
||||
|
||||
export const CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH = 8_000;
|
||||
export const CONTEXT_GUIDANCE_EXAMPLE = `仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
|
||||
conversationType是群聊时回复严肃一点,conversationType是私聊时回复一定要幽默搞笑,像周星驰的电影一样搞笑`;
|
||||
|
||||
// Kept as an alias for integrations that imported the original template name.
|
||||
export const DEFAULT_CONTEXT_GUIDANCE = CONTEXT_GUIDANCE_EXAMPLE;
|
||||
|
||||
export const DEFAULT_CONTEXT_ENHANCEMENT_CONFIG = Object.freeze({
|
||||
groupEnabled: false,
|
||||
directEnabled: false,
|
||||
fields: Object.freeze(['senderId']),
|
||||
guidance: '',
|
||||
});
|
||||
|
||||
const CONFIG_KEYS = ['groupEnabled', 'directEnabled', 'fields', 'guidance'];
|
||||
const CHANNELS = new Set([
|
||||
'wecom', 'weixin', 'feishu', 'dingtalk', 'qq',
|
||||
'slack', 'telegram', 'discord', 'whatsapp',
|
||||
]);
|
||||
const SOURCE_LIMITS = { channel: 16, conversationType: 6, senderId: 256, senderName: 256, botId: 128 };
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
|
||||
|
||||
function invalidConfig(message) {
|
||||
const error = new TypeError(message);
|
||||
error.code = 'context-enhancement-invalid';
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Validate the complete atomic save, preserving explicit empty selections/text. */
|
||||
export function validateContextEnhancementConfig(input) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)
|
||||
|| ![Object.prototype, null].includes(Object.getPrototypeOf(input))
|
||||
|| Reflect.ownKeys(input).length !== CONFIG_KEYS.length
|
||||
|| !CONFIG_KEYS.every((key) => Object.hasOwn(input, key))) {
|
||||
throw invalidConfig('请提交完整的上下文增强设置。');
|
||||
}
|
||||
const { groupEnabled, directEnabled, fields, guidance } = input;
|
||||
if (typeof groupEnabled !== 'boolean' || typeof directEnabled !== 'boolean') {
|
||||
throw invalidConfig('群聊和私聊开关必须是布尔值。');
|
||||
}
|
||||
if (!Array.isArray(fields) || ![...fields].every((field) => CONTEXT_ENHANCEMENT_FIELDS.includes(field))) {
|
||||
throw invalidConfig('来源字段只能选择已定义的五个字段。');
|
||||
}
|
||||
if (typeof guidance !== 'string' || guidance.length > CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH) {
|
||||
throw invalidConfig(`增强提示词不得超过 ${CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH} 个字符。`);
|
||||
}
|
||||
return Object.freeze({
|
||||
groupEnabled,
|
||||
directEnabled,
|
||||
fields: Object.freeze(CONTEXT_ENHANCEMENT_FIELDS.filter((field) => fields.includes(field))),
|
||||
guidance: guidance.trim() ? guidance : '',
|
||||
});
|
||||
}
|
||||
|
||||
/** Missing or damaged enhancement settings must never break an existing bot. */
|
||||
export function normalizeContextEnhancementConfig(input) {
|
||||
try {
|
||||
return validateContextEnhancementConfig(input);
|
||||
} catch {
|
||||
return DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture before queueing. The off path reads only the applicable switch. */
|
||||
export function captureContextEnhancement(provider, conversationType) {
|
||||
if (conversationType !== 'group' && conversationType !== 'direct') return null;
|
||||
try {
|
||||
const settings = provider?.getSettings?.();
|
||||
const enabledKey = conversationType === 'group' ? 'groupEnabled' : 'directEnabled';
|
||||
if (settings?.[enabledKey] !== true) return null;
|
||||
const config = normalizeContextEnhancementConfig(settings);
|
||||
if (config[enabledKey] !== true) return null;
|
||||
return Object.freeze({ config, botId: provider.botId, conversationType });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceString(value, field) {
|
||||
if (field === 'senderId' && (typeof value === 'bigint' || Number.isFinite(value))) {
|
||||
value = String(value);
|
||||
}
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.replace(CONTROL_CHARACTERS, '').trim().slice(0, SOURCE_LIMITS[field]);
|
||||
if (!normalized || (field === 'channel' && !CHANNELS.has(normalized))) return undefined;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sourceBlock(snapshot, sourceFactory) {
|
||||
const { fields } = snapshot.config;
|
||||
const needsSource = fields.some((field) => ['channel', 'senderId', 'senderName'].includes(field));
|
||||
const source = needsSource ? sourceFactory?.() : null;
|
||||
const projected = {};
|
||||
for (const field of fields) {
|
||||
const value = field === 'botId' || field === 'conversationType'
|
||||
? snapshot[field] : source?.[field];
|
||||
const normalized = sourceString(value, field);
|
||||
if (normalized !== undefined) projected[field] = normalized;
|
||||
}
|
||||
if (Object.keys(projected).length === 0) return '';
|
||||
const json = JSON.stringify(projected).replace(/[<>&]/g, (character) => ({
|
||||
'<': '\\u003c', '>': '\\u003e', '&': '\\u0026',
|
||||
})[character]);
|
||||
return `<dsh_im_source>${json}</dsh_im_source>`;
|
||||
}
|
||||
|
||||
function guidanceBlock(guidance) {
|
||||
if (!guidance.trim()) return '';
|
||||
const body = guidance.replace(/<\/?dsh_im_source_guidance\b[^>]*(?:>|$)/gi, (tag) => (
|
||||
tag.replace(/</g, '<').replace(/>/g, '>')
|
||||
));
|
||||
return `<dsh_im_source_guidance>\n${body}\n</dsh_im_source_guidance>`;
|
||||
}
|
||||
|
||||
/** Add one text prefix; never inspect sources, format or copy content when off. */
|
||||
export function enhanceContextContent(content, snapshot, sourceFactory) {
|
||||
if (!snapshot) return content;
|
||||
try {
|
||||
const blocks = [sourceBlock(snapshot, sourceFactory), guidanceBlock(snapshot.config.guidance)]
|
||||
.filter(Boolean);
|
||||
if (blocks.length === 0) return content;
|
||||
const prefix = blocks.join('\n\n');
|
||||
if (typeof content === 'string') return `${prefix}\n\n${content}`;
|
||||
if (Array.isArray(content)) return [{ type: 'text', text: prefix }, ...content];
|
||||
return content;
|
||||
} catch {
|
||||
// Only enhancement errors are isolated; the caller's original flow proceeds.
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { t } from './i18n.mjs';
|
||||
import { captureContextEnhancement, enhanceContextContent } from './context-enhancement.mjs';
|
||||
import { runWorkspaceCommand } from './workspace-command.mjs';
|
||||
import { runCompactCommand } from './compact-command.mjs';
|
||||
import { isHistoryCommand, runHistoryCommand } from './history-command.mjs';
|
||||
|
|
@ -127,6 +128,7 @@ export class TextHarnessBridge {
|
|||
#bot;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#status;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
|
|
@ -134,7 +136,8 @@ export class TextHarnessBridge {
|
|||
#queues = new Map();
|
||||
#pendingInteractions = new Map();
|
||||
#interactionKeys = new Map();
|
||||
#acceptedMessageIds = new Set();
|
||||
// Keep the accepted configuration through the existing queue/reply lifecycle.
|
||||
#acceptedMessageIds = new Map();
|
||||
#approvalTasks = new Set();
|
||||
#commandTasks = new Set();
|
||||
#approvals;
|
||||
|
|
@ -145,6 +148,7 @@ export class TextHarnessBridge {
|
|||
bot,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
status = createTextBridgeStatus(),
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
|
|
@ -157,6 +161,7 @@ export class TextHarnessBridge {
|
|||
this.#bot = bot;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#status = status;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
|
|
@ -171,7 +176,7 @@ export class TextHarnessBridge {
|
|||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
accept(message) {
|
||||
accept(message, { contextSnapshot } = {}) {
|
||||
if (this.#signal?.aborted) return Promise.resolve();
|
||||
const conversationId = cleanText(message?.conversationId);
|
||||
const kind = message?.kind === 'group' ? 'group' : 'direct';
|
||||
|
|
@ -182,7 +187,9 @@ export class TextHarnessBridge {
|
|||
|| this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.#acceptedMessageIds.add(messageId);
|
||||
this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined
|
||||
? captureContextEnhancement(this.#contextEnhancement, message?.kind)
|
||||
: contextSnapshot);
|
||||
const statusReaction = beginStatusReaction({
|
||||
adapter: this.#bot,
|
||||
target: normalized.kind === 'direct' || normalized.addressed === true
|
||||
|
|
@ -614,9 +621,17 @@ export class TextHarnessBridge {
|
|||
);
|
||||
}
|
||||
}
|
||||
const content = hasImages
|
||||
let content = hasImages
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
channel: this.#descriptor.key,
|
||||
senderId,
|
||||
senderName: message.contextSource?.()?.senderName,
|
||||
}));
|
||||
}
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
|
|
|
|||
|
|
@ -346,6 +346,7 @@ export class SlackRuntime {
|
|||
#appToken;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#connectTimeoutMs;
|
||||
|
|
@ -369,6 +370,7 @@ export class SlackRuntime {
|
|||
appToken,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
connectTimeoutMs = 20_000,
|
||||
|
|
@ -384,6 +386,7 @@ export class SlackRuntime {
|
|||
this.#appToken = appToken;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
|
|
@ -436,6 +439,7 @@ export class SlackRuntime {
|
|||
bot: client,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
contextEnhancement: this.#contextEnhancement,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { randomInt } from 'node:crypto';
|
|||
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
||||
import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
|
||||
import { COMMANDS_MENU_BUTTON, TelegramApi } from './telegram-api.mjs';
|
||||
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
|
||||
import {
|
||||
|
|
@ -159,6 +160,11 @@ export function normalizeTelegramUpdate(update, {
|
|||
return {
|
||||
messageId: String(update.update_id),
|
||||
senderId: String(senderId),
|
||||
contextSource: () => ({
|
||||
senderName: [message.from?.first_name, message.from?.last_name]
|
||||
.filter((value) => typeof value === 'string' && value.trim())
|
||||
.map((value) => value.trim()).join(' ') || message.from?.username,
|
||||
}),
|
||||
senderIsBot: message.from?.is_bot === true,
|
||||
kind: direct ? 'direct' : 'group',
|
||||
conversationId: messageThreadId === undefined
|
||||
|
|
@ -659,6 +665,7 @@ export class TelegramRuntime {
|
|||
#token;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#createApi;
|
||||
|
|
@ -676,6 +683,7 @@ export class TelegramRuntime {
|
|||
token,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
createApi = (options) => new TelegramApi(options),
|
||||
|
|
@ -687,6 +695,7 @@ export class TelegramRuntime {
|
|||
this.#token = token;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#createApi = createApi;
|
||||
|
|
@ -758,6 +767,7 @@ export class TelegramRuntime {
|
|||
bot: client,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
contextEnhancement: this.#contextEnhancement,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
|
|
@ -798,7 +808,19 @@ export class TelegramRuntime {
|
|||
while (!signal.aborted) {
|
||||
const updates = await this.#api.getUpdates({ offset: cursor, timeout: 25, signal });
|
||||
this.#status.lastCheckedAt = Date.now();
|
||||
for (const update of updates) {
|
||||
if (signal.aborted) return;
|
||||
// All updates have arrived together; cursor persistence must not move the
|
||||
// settings boundary for the later messages in this received batch.
|
||||
const received = updates.map((update) => {
|
||||
const chatType = update?.message?.chat?.type;
|
||||
return {
|
||||
update,
|
||||
contextSnapshot: captureContextEnhancement(this.#contextEnhancement,
|
||||
chatType === 'private' ? 'direct'
|
||||
: chatType === 'group' || chatType === 'supergroup' ? 'group' : null),
|
||||
};
|
||||
});
|
||||
for (const { update, contextSnapshot } of received) {
|
||||
if (signal.aborted) return;
|
||||
const message = normalizeTelegramUpdate(update, {
|
||||
botId: this.#config.platformId,
|
||||
|
|
@ -810,7 +832,7 @@ export class TelegramRuntime {
|
|||
accessMode: this.#accessMode,
|
||||
allowedPrivateUserIds: this.#allowedPrivateUserIds,
|
||||
})) {
|
||||
void this.#bridge.accept(message).catch((error) => {
|
||||
void this.#bridge.accept(message, { contextSnapshot }).catch((error) => {
|
||||
if (signal.aborted) return;
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:telegram] bot ${this.#config.botId} message handling failed:`,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
ImagePromptError,
|
||||
|
|
@ -488,6 +489,7 @@ export class WecomHarnessBridge {
|
|||
#client;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#status;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
|
|
@ -497,7 +499,8 @@ export class WecomHarnessBridge {
|
|||
#queues = new Map();
|
||||
#pendingInteractions = new Map();
|
||||
#interactionKeys = new Map();
|
||||
#acceptedMessageIds = new Set();
|
||||
// Keep the accepted configuration through the existing queue/reply lifecycle.
|
||||
#acceptedMessageIds = new Map();
|
||||
#approvalTasks = new Set();
|
||||
#commandTasks = new Set();
|
||||
#approvals;
|
||||
|
|
@ -508,6 +511,7 @@ export class WecomHarnessBridge {
|
|||
client,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
status = createWecomBridgeStatus(),
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
|
|
@ -525,6 +529,7 @@ export class WecomHarnessBridge {
|
|||
this.#client = client;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#status = status;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
|
|
@ -552,7 +557,10 @@ export class WecomHarnessBridge {
|
|||
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
||||
|
||||
const key = conversationKey(frame);
|
||||
this.#acceptedMessageIds.add(messageId);
|
||||
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
|
||||
this.#contextEnhancement,
|
||||
body.chattype === 'single' ? 'direct' : 'group',
|
||||
));
|
||||
if (body.chattype === 'single') {
|
||||
rememberConnectionTestTarget(this.#state, { chatId });
|
||||
}
|
||||
|
|
@ -948,9 +956,16 @@ export class WecomHarnessBridge {
|
|||
this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
|
||||
}
|
||||
|
||||
const content = hasImages
|
||||
let content = hasImages
|
||||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
channel: 'wecom',
|
||||
senderId,
|
||||
}));
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
promptRecorded = true;
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export class WecomRuntime {
|
|||
#secret;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#connectTimeoutMs;
|
||||
|
|
@ -45,6 +46,7 @@ export class WecomRuntime {
|
|||
secret,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
connectTimeoutMs = 20_000,
|
||||
|
|
@ -58,6 +60,7 @@ export class WecomRuntime {
|
|||
this.#secret = secret;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
|
|
@ -107,6 +110,7 @@ export class WecomRuntime {
|
|||
client,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
contextEnhancement: this.#contextEnhancement,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
} from '../shared/preset-command.mjs';
|
||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||
import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
imagePromptDiagnostic,
|
||||
|
|
@ -258,6 +259,7 @@ export class WeixinHarnessBridge {
|
|||
#ownerUserId;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#status;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
|
|
@ -267,7 +269,8 @@ export class WeixinHarnessBridge {
|
|||
#queues = new Map();
|
||||
#pendingInteractions = new Map();
|
||||
#interactionKeys = new Map();
|
||||
#acceptedMessageIds = new Set();
|
||||
// Keep the accepted configuration through the existing queue/reply lifecycle.
|
||||
#acceptedMessageIds = new Map();
|
||||
#approvalTasks = new Set();
|
||||
#commandTasks = new Set();
|
||||
#approvals;
|
||||
|
|
@ -288,6 +291,7 @@ export class WeixinHarnessBridge {
|
|||
ownerUserId,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
status = createWeixinBridgeStatus(),
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
|
|
@ -307,6 +311,7 @@ export class WeixinHarnessBridge {
|
|||
this.#ownerUserId = ownerUserId;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#status = status;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
|
|
@ -327,7 +332,10 @@ export class WeixinHarnessBridge {
|
|||
const sender = nonEmptyString(message?.from_user_id);
|
||||
if (!messageId || !sender || this.#state.hasSeen(messageId)
|
||||
|| this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
|
||||
this.#acceptedMessageIds.add(messageId);
|
||||
this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
|
||||
this.#contextEnhancement,
|
||||
'direct',
|
||||
));
|
||||
if (sender === this.#ownerUserId) {
|
||||
rememberConnectionTestTarget(this.#state, { toUserId: sender });
|
||||
}
|
||||
|
|
@ -650,16 +658,23 @@ export class WeixinHarnessBridge {
|
|||
let artifacts = [];
|
||||
await this.#startTyping(sender, contextToken);
|
||||
try {
|
||||
const content = hasImages
|
||||
let content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
channel: 'weixin',
|
||||
senderId: sender,
|
||||
}));
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
promptRecorded = true;
|
||||
({ answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
...(hasImages ? { content } : { text }),
|
||||
...(content !== undefined ? { content } : { text }),
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export class WeixinRuntime {
|
|||
#token;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#maxMessageChars;
|
||||
|
|
@ -124,6 +125,7 @@ export class WeixinRuntime {
|
|||
token,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
|
||||
|
|
@ -137,6 +139,7 @@ export class WeixinRuntime {
|
|||
this.#token = token;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#maxMessageChars = maxMessageChars;
|
||||
|
|
@ -178,6 +181,7 @@ export class WeixinRuntime {
|
|||
ownerUserId: this.#config.ownerUserId,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
contextEnhancement: this.#contextEnhancement,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
|
|||
messageId: `${remoteJid}:${messageId}`,
|
||||
providerMessageId: messageId,
|
||||
senderId: senderJid,
|
||||
contextSource: () => ({ senderName: message.pushName }),
|
||||
senderAlternateId: typeof senderAlternateJid === 'string' ? senderAlternateJid : '',
|
||||
senderIsBot: false,
|
||||
kind: group ? 'group' : 'direct',
|
||||
|
|
@ -536,6 +537,7 @@ export class WhatsappRuntime {
|
|||
#authDir;
|
||||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#connectTimeoutMs;
|
||||
|
|
@ -555,6 +557,7 @@ export class WhatsappRuntime {
|
|||
authDir,
|
||||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
connectTimeoutMs = 30_000,
|
||||
|
|
@ -568,6 +571,7 @@ export class WhatsappRuntime {
|
|||
this.#authDir = authDir;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
|
|
@ -673,6 +677,7 @@ export class WhatsappRuntime {
|
|||
bot: client,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
contextEnhancement: this.#contextEnhancement,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
|
|||
deleteBot: 'bot.delete',
|
||||
setWorkspace: 'bot.workspace.set',
|
||||
setAgentPreset: 'bot.preset.set',
|
||||
setContextEnhancement: 'bot.context-enhancement.set',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -226,6 +226,84 @@ test('runtime sends visible-scope messages to Harness without local sender appro
|
|||
await runtime.stop();
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'private enabled to disabled', conversationType: '1', before: [true, false], after: [false, true] },
|
||||
{ name: 'group enabled to disabled', conversationType: '2', before: [false, true], after: [true, false] },
|
||||
{ name: 'private disabled to enabled', conversationType: '1', before: [false, true], after: [true, false] },
|
||||
]) {
|
||||
test(`DingTalk received callback retains settings before its parse microtask: ${scenario.name}`, async () => {
|
||||
const order = [];
|
||||
const asked = [];
|
||||
const configFor = ([directEnabled, groupEnabled], guidance) => ({
|
||||
directEnabled, groupEnabled, fields: ['channel', 'botId'], guidance,
|
||||
});
|
||||
let config = configFor(scenario.before, 'before callback returned');
|
||||
let callback;
|
||||
const client = {
|
||||
connected: true,
|
||||
socket: { readyState: 1 },
|
||||
registerCallbackListener(_topic, listener) { callback = listener; },
|
||||
async connect() {},
|
||||
socketCallBackResponse(messageId) { order.push(['ack', messageId]); },
|
||||
disconnect() {},
|
||||
};
|
||||
const runtime = new DingtalkRuntime({
|
||||
config: { clientId: 'ding-client', approvedSenders: [] },
|
||||
clientSecret: 'host-secret',
|
||||
contextEnhancement: {
|
||||
botId: 'dingtalk_internal',
|
||||
getSettings: () => { order.push(['settings']); return config; },
|
||||
},
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
createSession: async () => 'session-existing',
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => { asked.push(text); order.push(['ask']); return 'done'; },
|
||||
},
|
||||
state: stateFixture(),
|
||||
api: { sendText: async () => {} },
|
||||
streamFactory: async () => ({ client, topic: 'robot-topic' }),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
const response = (id) => ({
|
||||
headers: { messageId: `callback-${id}` },
|
||||
get data() {
|
||||
order.push(['read-data', id]);
|
||||
return JSON.stringify({
|
||||
msgId: `message-${id}`, msgtype: 'text', text: { content: `message ${id}` },
|
||||
conversationType: scenario.conversationType, conversationId: 'group-chat',
|
||||
isInAtList: true, senderStaffId: 'staff-id', senderNick: 'Ada',
|
||||
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=test',
|
||||
});
|
||||
},
|
||||
});
|
||||
try {
|
||||
await runtime.start();
|
||||
callback(response(1));
|
||||
assert.deepEqual(order, [['ack', 'callback-1'], ['settings']], 'ACK stays first and JSON remains unparsed until the original microtask');
|
||||
config = configFor(scenario.after, 'after callback returned');
|
||||
await eventually(() => runtime.status.messagesReplied === 1, 5_000);
|
||||
callback(response(2));
|
||||
await eventually(() => runtime.status.messagesReplied === 2, 5_000);
|
||||
for (const [index, switches] of [scenario.before, scenario.after].entries()) {
|
||||
const enabled = switches[scenario.conversationType === '1' ? 0 : 1];
|
||||
if (enabled) {
|
||||
assert.match(asked[index], index === 0 ? /before callback returned/ : /after callback returned/);
|
||||
assert.deepEqual(JSON.parse(/^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(asked[index])[1]), {
|
||||
channel: 'dingtalk', botId: 'dingtalk_internal',
|
||||
});
|
||||
} else {
|
||||
assert.equal(asked[index], `message ${index + 1}`);
|
||||
}
|
||||
}
|
||||
assert.equal(order.filter(([action]) => action === 'settings').length, 2, 'one source configuration read per callback');
|
||||
assert.equal(order.filter(([action]) => action === 'read-data').length, 4, 'preserves the original data parsing path');
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('runtime accepts an OPEN DingTalk socket when the SDK registered flag remains false', async () => {
|
||||
const client = {
|
||||
connected: true,
|
||||
|
|
|
|||
|
|
@ -1425,6 +1425,92 @@ test('Discord runtime keeps one isolated Session per managed thread and reuses i
|
|||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('Discord captures context settings before asynchronous Thread routing and updates future messages live', async (t) => {
|
||||
const botId = '1234567890123456789';
|
||||
const parentId = '222222222222222290';
|
||||
const threadId = '111111111111111190';
|
||||
const routingStarted = deferred();
|
||||
const releaseRouting = deferred();
|
||||
const seen = new Set();
|
||||
const prompts = [];
|
||||
let socket;
|
||||
let reads = 0;
|
||||
let config = {
|
||||
groupEnabled: true,
|
||||
directEnabled: false,
|
||||
fields: ['channel', 'conversationType', 'senderId', 'senderName', 'botId'],
|
||||
guidance: 'accepted before routing',
|
||||
};
|
||||
const runtime = new DiscordRuntime({
|
||||
config: { botId: 'discord_internal', platformId: botId, name: 'Harness Discord' },
|
||||
token: TOKEN,
|
||||
contextEnhancement: {
|
||||
botId: 'discord_internal',
|
||||
getSettings: () => { reads += 1; return config; },
|
||||
},
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, content) => { prompts.push(content); return 'answer'; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
},
|
||||
createApi: () => ({
|
||||
getCurrentUser: async () => ({ id: botId, bot: true }),
|
||||
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
|
||||
getChannel: async () => assert.fail('The channel is already in the gateway cache'),
|
||||
startThreadFromMessage: async () => {
|
||||
routingStarted.resolve();
|
||||
await releaseRouting.promise;
|
||||
return { id: threadId, type: 11, parent_id: parentId, owner_id: botId };
|
||||
},
|
||||
sendTyping: async () => {},
|
||||
createMessage: async () => ({ id: '888888888888888890' }),
|
||||
editMessage: async ({ messageId }) => ({ id: messageId }),
|
||||
}),
|
||||
createWebSocket: () => {
|
||||
socket = new FakeSocket();
|
||||
queueMicrotask(() => socket.emit('message', {
|
||||
data: JSON.stringify({ op: 10, d: { heartbeat_interval: 45_000 } }),
|
||||
}));
|
||||
return socket;
|
||||
},
|
||||
random: () => 0.5,
|
||||
logger: { warn() {}, error(...args) { assert.fail(args.join(' ')); } },
|
||||
});
|
||||
t.after(async () => { releaseRouting.resolve(); await runtime.stop(); });
|
||||
await runtime.start();
|
||||
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'GUILD_CREATE', s: 2,
|
||||
d: { id: '444444444444444444', channels: [{ id: parentId, type: 0 }], threads: [] },
|
||||
}) });
|
||||
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 3, d: {
|
||||
id: threadId, channel_id: parentId, guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333333', bot: false, global_name: 'Global Name', username: 'username' },
|
||||
member: { nick: 'Group Nick' }, mentions: [{ id: botId }], content: `<@${botId}> first`,
|
||||
} }) });
|
||||
await routingStarted.promise;
|
||||
config = { ...config, groupEnabled: false };
|
||||
releaseRouting.resolve();
|
||||
await eventually(() => runtime.status.messagesReplied === 1);
|
||||
assert.match(prompts[0], /accepted before routing/);
|
||||
assert.deepEqual(JSON.parse(/^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(prompts[0])[1]), {
|
||||
channel: 'discord', conversationType: 'group', senderId: '333333333333333333',
|
||||
senderName: 'Group Nick', botId: 'discord_internal',
|
||||
});
|
||||
assert.equal(reads, 1, 'routing and Bridge share one accepted configuration read');
|
||||
|
||||
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 4, d: {
|
||||
id: '111111111111111191', channel_id: threadId, guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333333', bot: false }, content: 'second without enhancement',
|
||||
} }) });
|
||||
await eventually(() => runtime.status.messagesReplied === 2);
|
||||
assert.equal(prompts[1], 'second without enhancement');
|
||||
assert.equal(reads, 2);
|
||||
});
|
||||
|
||||
test('Discord runtime records one uncertain Thread result and suppresses Gateway replays', async () => {
|
||||
const botId = '1234567890123456789';
|
||||
const parentChannelId = '222222222222222290';
|
||||
|
|
|
|||
492
test/channels/shared/context-enhancement-bridges.test.mjs
Normal file
492
test/channels/shared/context-enhancement-bridges.test.mjs
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DingtalkHarnessBridge } from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
|
||||
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
|
||||
import { normalizeDiscordMessage } from '../../../src/channels/discord/discord-runtime.mjs';
|
||||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
|
||||
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
|
||||
import { normalizeSlackEvent } from '../../../src/channels/slack/slack-runtime.mjs';
|
||||
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
|
||||
import { normalizeTelegramUpdate } from '../../../src/channels/telegram/telegram-runtime.mjs';
|
||||
import { WecomHarnessBridge } from '../../../src/channels/wecom/wecom-bridge.mjs';
|
||||
import { WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
|
||||
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
|
||||
import { normalizeWhatsappMessage } from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||
import { CONTEXT_ENHANCEMENT_FIELDS } from '../../../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
const CHANNELS = ['wecom', 'weixin', 'feishu', 'dingtalk', 'qq', 'slack', 'telegram', 'discord', 'whatsapp'];
|
||||
const TEXT_BRIDGES = { slack: SlackHarnessBridge, telegram: TelegramHarnessBridge,
|
||||
discord: DiscordHarnessBridge, whatsapp: WhatsappHarnessBridge };
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const FILE = Buffer.from('unchanged file bytes');
|
||||
const logger = { info() {}, warn() {}, error() {} };
|
||||
|
||||
function settings(overrides = {}) {
|
||||
return { groupEnabled: true, directEnabled: true, fields: [...CONTEXT_ENHANCEMENT_FIELDS], guidance: '', ...overrides };
|
||||
}
|
||||
|
||||
function provider(channel, config) {
|
||||
return { botId: `${channel}_internal`, getSettings: () => config };
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function eventually(predicate) {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.fail('Expected message processing did not finish');
|
||||
}
|
||||
|
||||
function sourceOf(content) {
|
||||
const text = Array.isArray(content) ? content[0].text : content;
|
||||
const match = /^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(text);
|
||||
assert.ok(match, `No source in ${text}`);
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
|
||||
function withoutPrompt(calls) {
|
||||
return calls.map(([operation, ...args]) => operation === 'ask'
|
||||
? [operation, args[0], '(prompt)', args[2]] : [operation, ...args]);
|
||||
}
|
||||
|
||||
function fixture(channel, { contextEnhancement, onAsk } = {}) {
|
||||
const calls = [];
|
||||
const prompts = [];
|
||||
const sessions = new Map();
|
||||
const seen = new Set();
|
||||
let sourceReads = 0;
|
||||
const state = {
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => { seen.add(id); calls.push(['seen', id]); },
|
||||
sessionFor: (key) => sessions.get(key) ?? null,
|
||||
setSession: async (key, id) => { sessions.set(key, id); calls.push(['bind', key, id]); },
|
||||
clearSession: async (key) => { sessions.delete(key); calls.push(['clear', key]); },
|
||||
pendingSenders: () => [],
|
||||
};
|
||||
const harness = {
|
||||
ensureRunning: async () => { calls.push(['ensureRunning']); },
|
||||
createSession: async () => { calls.push(['createSession']); return 'session-existing'; },
|
||||
sessionExists: async (id) => { calls.push(['sessionExists', id]); return true; },
|
||||
hasActiveTurn: async () => false,
|
||||
isSessionRunning: async () => false,
|
||||
currentWorkspace: () => null,
|
||||
ask: async (sessionId, content, options) => {
|
||||
prompts.push(content);
|
||||
calls.push(['ask', sessionId, content, Object.keys(options).sort()]);
|
||||
for (const file of options.files ?? []) {
|
||||
const loaded = await file.load({ signal: options.signal });
|
||||
calls.push(['file', file.name, Buffer.from(loaded?.data ?? loaded).toString('hex')]);
|
||||
}
|
||||
return onAsk ? onAsk({ sessionId, content, options, prompts }) : 'answer unchanged';
|
||||
},
|
||||
};
|
||||
const sendText = async (...args) => {
|
||||
calls.push(['sendText', ...args]);
|
||||
return { messageId: 'reply-one', id: 'reply-one' };
|
||||
};
|
||||
const image = (name) => ({ name, mediaType: 'image/png', load: async () => {
|
||||
calls.push(['image', name]); return PNG;
|
||||
} });
|
||||
const file = () => ({ name: 'report.txt', load: async () => {
|
||||
calls.push(['file-download']); return FILE;
|
||||
} });
|
||||
const dependencies = { harness, state, contextEnhancement, logger };
|
||||
let bridge;
|
||||
if (TEXT_BRIDGES[channel]) {
|
||||
bridge = new TEXT_BRIDGES[channel]({ ...dependencies, bot: { sendText } });
|
||||
} else if (channel === 'wecom') {
|
||||
bridge = new WecomHarnessBridge({ ...dependencies, generateStreamId: () => 'stream-one', client: {
|
||||
replyStream: async (frame, streamId, text, finish) => {
|
||||
calls.push(['replyStream', frame.body.msgid, streamId, text, finish]);
|
||||
return { body: { msgid: 'reply-one' } };
|
||||
},
|
||||
sendMessage: sendText,
|
||||
downloadFile: async (url) => {
|
||||
calls.push(['downloadFile', url]);
|
||||
return { buffer: url.endsWith('.png') ? PNG : FILE, filename: url.split('/').at(-1) };
|
||||
},
|
||||
} });
|
||||
} else if (channel === 'weixin') {
|
||||
bridge = new WeixinHarnessBridge({ ...dependencies, baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'private-token', ownerUserId: 'actor', api: {
|
||||
sendText: async ({ toUserId, text, contextToken, runId }) => sendText({ toUserId, text, contextToken, runId }),
|
||||
inboundImages: (event) => event.item_list.filter((item) => item.image_item).map((_item, index) => image(`image-${index}.png`)),
|
||||
inboundFiles: (event) => event.item_list.filter((item) => item.file_item).map(file),
|
||||
} });
|
||||
} else if (channel === 'dingtalk') {
|
||||
bridge = new DingtalkHarnessBridge({ ...dependencies, clientId: 'app', clientSecret: 'private-secret', api: {
|
||||
sendText: async ({ sessionWebhook, text, at }) => sendText({ sessionWebhook, text, at }),
|
||||
downloadImage: async ({ downloadCode }) => { calls.push(['downloadImage', downloadCode]); return PNG; },
|
||||
downloadFile: async ({ downloadCode }) => { calls.push(['downloadFile', downloadCode]); return FILE; },
|
||||
} });
|
||||
} else if (channel === 'qq') {
|
||||
bridge = new QqHarnessBridge({ ...dependencies, ownerUserOpenid: '*', bot: { sendText },
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(['fetch', String(url)]);
|
||||
const isImage = String(url).endsWith('.png');
|
||||
return new Response(isImage ? PNG : FILE, { headers: { 'content-type': isImage ? 'image/png' : 'text/plain' } });
|
||||
},
|
||||
});
|
||||
} else {
|
||||
bridge = new FeishuHarnessBridge({ ...dependencies, status: {}, allowedSenderOpenIds: new Set(['*']),
|
||||
channel: {}, client: { im: { v1: {
|
||||
message: { create: async (request) => {
|
||||
calls.push(['createMessage', request]);
|
||||
return { code: 0, data: { message_id: 'reply-one' } };
|
||||
} },
|
||||
messageResource: { get: async ({ path, params }) => {
|
||||
calls.push(['messageResource', path, params]);
|
||||
const bytes = params.type === 'image' ? PNG : FILE;
|
||||
return { headers: { 'content-length': String(bytes.length) }, getReadableStream: () => Readable.from([bytes]) };
|
||||
} },
|
||||
} } },
|
||||
});
|
||||
}
|
||||
|
||||
function event(id, text = ' \t user text\nsecond line ', { kind = 'direct', name = 'Ada', actor = 'actor', media, poisonName = false } = {}) {
|
||||
const group = kind === 'group';
|
||||
const nameValue = (object, key) => Object.defineProperty(object, key, { configurable: true, get() {
|
||||
sourceReads += 1;
|
||||
if (poisonName) throw new Error('enhancement-only source must not be read');
|
||||
return name;
|
||||
} });
|
||||
let value;
|
||||
if (channel === 'telegram') {
|
||||
const from = { id: actor === 'actor' ? 42 : 43, is_bot: false };
|
||||
nameValue(from, 'first_name');
|
||||
value = normalizeTelegramUpdate({ update_id: id, message: {
|
||||
message_id: id, chat: { id: 100, type: group ? 'supergroup' : 'private' }, from,
|
||||
text: group ? `@testbot ${text}` : text, entities: group ? [{ type: 'mention', offset: 0, length: 8 }] : [],
|
||||
} }, { botId: 'bot', username: 'testbot' });
|
||||
} else if (channel === 'discord') {
|
||||
const author = { id: actor, bot: false };
|
||||
nameValue(author, 'global_name');
|
||||
value = normalizeDiscordMessage({ id: String(id), channel_id: 'chat', author,
|
||||
guild_id: group ? 'guild' : undefined, content: text, mentions: [{ id: 'bot' }],
|
||||
}, 'bot');
|
||||
} else if (channel === 'whatsapp') {
|
||||
const raw = { key: { id: String(id), remoteJid: group ? 'chat@g.us' : `${actor}@s.whatsapp.net`,
|
||||
participant: group ? `${actor}@s.whatsapp.net` : undefined, fromMe: false },
|
||||
message: { extendedTextMessage: { text, contextInfo: { mentionedJid: ['bot@s.whatsapp.net'] } } } };
|
||||
nameValue(raw, 'pushName');
|
||||
value = normalizeWhatsappMessage(raw, 'bot@s.whatsapp.net');
|
||||
} else if (channel === 'slack') {
|
||||
value = normalizeSlackEvent({ event_id: String(id), event: { type: group ? 'app_mention' : 'message',
|
||||
ts: String(id), channel: 'chat', channel_type: group ? 'channel' : 'im', user: actor,
|
||||
thread_ts: group ? 'thread-existing' : undefined,
|
||||
text: group ? `<@bot> ${text}` : text,
|
||||
} }, 'bot');
|
||||
} else if (channel === 'wecom') {
|
||||
value = { headers: { req_id: String(id) }, body: { msgid: String(id), from: { userid: actor },
|
||||
chattype: group ? 'group' : 'single', chatid: 'chat', msgtype: 'text', text: { content: text } } };
|
||||
} else if (channel === 'weixin') {
|
||||
value = { message_id: String(id), message_type: 1, from_user_id: actor, context_token: 'context',
|
||||
item_list: [{ type: 1, text_item: { text } }] };
|
||||
} else if (channel === 'dingtalk') {
|
||||
value = { msgId: String(id), msgtype: 'text', text: { content: text }, senderStaffId: actor,
|
||||
conversationType: group ? '2' : '1', conversationId: 'chat', isInAtList: true,
|
||||
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=test' };
|
||||
nameValue(value, 'senderNick');
|
||||
} else if (channel === 'qq') {
|
||||
value = { messageId: String(id), senderId: actor, kind: group ? 'group' : 'c2c', groupOpenid: 'chat',
|
||||
rawEventType: group ? 'GROUP_AT_MESSAGE_CREATE' : 'C2C_MESSAGE_CREATE', senderIsBot: false, content: text,
|
||||
replyTarget: { scope: group ? 'group' : 'c2c', targetId: group ? 'chat' : actor, msgId: String(id) } };
|
||||
nameValue(value, 'senderName');
|
||||
} else {
|
||||
value = { sender: { sender_type: 'user', sender_id: { open_id: actor } }, message: {
|
||||
message_id: String(id), message_type: 'text', chat_id: 'chat', chat_type: group ? 'group' : 'p2p',
|
||||
content: JSON.stringify({ text }), mentions: group ? [{ key: '@bot', id: { open_id: 'bot' } }] : [],
|
||||
} };
|
||||
}
|
||||
assert.ok(value, `${channel} event should normalize`);
|
||||
if (!media) return value;
|
||||
const imageNames = media === 'mixed' ? ['first.png', 'second.png'] : media === 'image' ? ['first.png'] : [];
|
||||
const includeFile = media === 'file' || media === 'mixed';
|
||||
if (TEXT_BRIDGES[channel]) {
|
||||
value.images = imageNames.map(image);
|
||||
value.files = includeFile ? [file()] : [];
|
||||
} else if (channel === 'wecom') {
|
||||
value.body.msgtype = 'mixed';
|
||||
value.body.mixed = { msg_item: [
|
||||
{ msgtype: 'text', text: { content: text } },
|
||||
...imageNames.map((name) => ({ msgtype: 'image', image: { url: `https://wecom.test/${name}` } })),
|
||||
...(includeFile ? [{ msgtype: 'file', file: { url: 'https://wecom.test/report.txt', filename: 'report.txt' } }] : []),
|
||||
] };
|
||||
} else if (channel === 'weixin') {
|
||||
value.item_list.push(...imageNames.map(() => ({ type: 2, image_item: {} })),
|
||||
...(includeFile ? [{ type: 4, file_item: { file_name: 'report.txt' } }] : []));
|
||||
} else if (channel === 'dingtalk') {
|
||||
value.msgtype = media === 'file' ? 'file' : 'richText';
|
||||
value.content = media === 'file' ? { downloadCode: 'file-one', fileName: 'report.txt' }
|
||||
: { richText: [{ text }, ...imageNames.map((downloadCode) => ({ type: 'picture', downloadCode }))] };
|
||||
} else if (channel === 'qq') {
|
||||
value.attachments = [
|
||||
...imageNames.map((name) => ({ filename: name, url: `https://gchat.qpic.cn/${name}`, content_type: 'image/png' })),
|
||||
...(includeFile ? [{ filename: 'report.txt', url: 'https://gchat.qpic.cn/report.txt', content_type: 'text/plain' }] : []),
|
||||
];
|
||||
} else {
|
||||
value.message.message_type = media === 'file' ? 'file' : 'post';
|
||||
value.message.content = JSON.stringify(media === 'file' ? { file_key: 'file-one', file_name: 'report.txt' }
|
||||
: { content: [[{ tag: 'text', text }, ...imageNames.map((image_key) => ({ tag: 'img', image_key }))]] });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return { bridge, event, calls, prompts, sessions, seen, get sourceReads() { return sourceReads; } };
|
||||
}
|
||||
|
||||
for (const channel of CHANNELS) {
|
||||
const kinds = channel === 'weixin' ? ['direct'] : ['direct', 'group'];
|
||||
for (const kind of kinds) {
|
||||
test(`${channel} ${kind}: disabled settings preserve text/media, calls and session bindings`, async () => {
|
||||
const oppositeOnly = settings({ groupEnabled: kind === 'direct', directEnabled: kind === 'group' });
|
||||
const poisonedOff = {
|
||||
groupEnabled: false, directEnabled: false,
|
||||
get fields() { throw new Error('off fields accessed'); },
|
||||
get guidance() { throw new Error('off guidance accessed'); },
|
||||
};
|
||||
const variants = [
|
||||
provider(channel, undefined),
|
||||
provider(channel, settings({ groupEnabled: false, directEnabled: false })),
|
||||
provider(channel, oppositeOnly),
|
||||
provider(channel, poisonedOff),
|
||||
provider(channel, { groupEnabled: 'true', directEnabled: 1 }),
|
||||
{ getSettings() { throw new Error('damaged optional settings'); } },
|
||||
];
|
||||
for (const media of [undefined, 'image', 'file', 'mixed']) {
|
||||
const baseline = fixture(channel);
|
||||
await baseline.bridge.accept(baseline.event(1, undefined, { kind, media, poisonName: true }));
|
||||
assert.equal(baseline.prompts.length, 1);
|
||||
assert.equal(baseline.sourceReads, 0);
|
||||
for (const contextEnhancement of variants) {
|
||||
const current = fixture(channel, { contextEnhancement });
|
||||
await current.bridge.accept(current.event(1, undefined, { kind, media, poisonName: true }));
|
||||
assert.deepEqual(current.calls, baseline.calls, `${media ?? 'text'}: preserve platform/Harness call order and arguments`);
|
||||
assert.deepEqual(current.sessions, baseline.sessions);
|
||||
assert.deepEqual(current.seen, baseline.seen);
|
||||
assert.equal(current.sourceReads, 0);
|
||||
assert.equal(current.bridge.status?.lastMessageError ?? null, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`${channel} ${kind}: enabled source uses the actual event, optional name and internal bot ID`, async () => {
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
|
||||
await current.bridge.accept(current.event(1, 'hello', { kind }));
|
||||
const expected = {
|
||||
channel, conversationType: kind,
|
||||
senderId: channel === 'telegram' ? '42' : channel === 'whatsapp' ? 'actor@s.whatsapp.net' : 'actor',
|
||||
...(['dingtalk', 'telegram', 'discord', 'whatsapp'].includes(channel) || (channel === 'qq' && kind === 'group')
|
||||
? { senderName: 'Ada' } : {}),
|
||||
botId: `${channel}_internal`,
|
||||
};
|
||||
assert.deepEqual(sourceOf(current.prompts[0]), expected);
|
||||
assert.ok(current.prompts[0].endsWith('\n\nhello'));
|
||||
assert.doesNotMatch(current.prompts[0], /source_guidance|private-token|private-secret/);
|
||||
// Explicit null represents a provider event that does not include a nickname.
|
||||
await current.bridge.accept(current.event(2, 'missing name', { kind, name: null }));
|
||||
assert.equal(Object.hasOwn(sourceOf(current.prompts[1]), 'senderName'), false);
|
||||
if (kind === 'group') {
|
||||
await current.bridge.accept(current.event(3, 'another sender', { kind, name: 'Grace', actor: 'actor-two' }));
|
||||
const next = sourceOf(current.prompts[2]);
|
||||
assert.notEqual(next.senderId, expected.senderId);
|
||||
if (expected.senderName) assert.equal(next.senderName, 'Grace');
|
||||
assert.equal(current.sessions.size, 1, 'group speakers keep sharing the existing group Session');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test(`${channel}: enabled image/file assembly adds one prefix and preserves the remaining payload`, async () => {
|
||||
for (const media of ['image', 'file', 'mixed']) {
|
||||
const plain = fixture(channel);
|
||||
const enabled = fixture(channel, { contextEnhancement: provider(channel, settings({ guidance: 'Use only provided fields.' })) });
|
||||
await plain.bridge.accept(plain.event(1, 'caption', { media }));
|
||||
await enabled.bridge.accept(enabled.event(1, 'caption', { media }));
|
||||
const original = plain.prompts[0];
|
||||
const enhanced = enabled.prompts[0];
|
||||
if (Array.isArray(original)) {
|
||||
assert.equal(enhanced[0].type, 'text');
|
||||
assert.deepEqual(enhanced.slice(1), original);
|
||||
assert.match(enhanced[0].text, /<dsh_im_source_guidance>\nUse only provided fields\./);
|
||||
} else {
|
||||
assert.ok(enhanced.endsWith(`\n\n${original}`));
|
||||
}
|
||||
assert.deepEqual(enabled.calls.filter(([op]) => op === 'file'), plain.calls.filter(([op]) => op === 'file'));
|
||||
assert.deepEqual(withoutPrompt(enabled.calls), withoutPrompt(plain.calls), 'enhancement adds no provider or Harness requests');
|
||||
}
|
||||
});
|
||||
|
||||
test(`${channel}: queued messages keep their accepted snapshot across saves and disablement`, async () => {
|
||||
let config = settings({ fields: ['channel'], guidance: 'version one' });
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const current = fixture(channel, {
|
||||
contextEnhancement: { botId: `${channel}_internal`, getSettings: () => config },
|
||||
onAsk: async ({ prompts }) => {
|
||||
if (prompts.length === 1) { started.resolve(); await release.promise; }
|
||||
return 'answer unchanged';
|
||||
},
|
||||
});
|
||||
const first = current.bridge.accept(current.event(1, 'first'));
|
||||
await started.promise;
|
||||
const queued = current.bridge.accept(current.event(2, 'queued'));
|
||||
config.guidance = 'mutated after acceptance';
|
||||
config = settings({ fields: ['botId'], guidance: 'version two' });
|
||||
const newer = current.bridge.accept(current.event(3, 'newer'));
|
||||
config = settings({ groupEnabled: false, directEnabled: false });
|
||||
const off = current.bridge.accept(current.event(4, 'off'));
|
||||
config = settings({ fields: ['botId'], guidance: 'version three' });
|
||||
const reenabled = current.bridge.accept(current.event(5, 'reenabled'));
|
||||
release.resolve();
|
||||
await Promise.all([first, queued, newer, off, reenabled]);
|
||||
assert.match(current.prompts[0], /version one/);
|
||||
assert.match(current.prompts[1], /version one/);
|
||||
assert.doesNotMatch(current.prompts[1], /mutated|version two/);
|
||||
assert.deepEqual(sourceOf(current.prompts[2]), { botId: `${channel}_internal` });
|
||||
assert.match(current.prompts[2], /version two/);
|
||||
assert.equal(current.prompts[3], 'off');
|
||||
assert.match(current.prompts[4], /version three/);
|
||||
assert.equal(current.calls.filter(([op]) => op === 'createSession').length, 1);
|
||||
});
|
||||
|
||||
test(`${channel}: batches gain one prefix only at final submission`, async () => {
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
|
||||
await current.bridge.accept(current.event(1, '/batch'));
|
||||
await current.bridge.accept(current.event(2, 'first item'));
|
||||
await current.bridge.accept(current.event(3, 'second item'));
|
||||
assert.equal(current.prompts.length, 0);
|
||||
await current.bridge.accept(current.event(4, '/send'));
|
||||
assert.equal(current.prompts.length, 1);
|
||||
assert.equal(current.prompts[0].split('<dsh_im_source>').length - 1, 1);
|
||||
assert.match(current.prompts[0], /first item[\s\S]*second item/);
|
||||
});
|
||||
|
||||
test(`${channel}: disabled batches retain the original one-submission behavior and calls`, async () => {
|
||||
const baseline = fixture(channel);
|
||||
const disabled = fixture(channel, {
|
||||
contextEnhancement: provider(channel, settings({ groupEnabled: false, directEnabled: false })),
|
||||
});
|
||||
for (const current of [baseline, disabled]) {
|
||||
for (const [index, text] of ['/batch', ' first item\n', 'second\nitem ', '/send'].entries()) {
|
||||
await current.bridge.accept(current.event(index + 1, text, { poisonName: true }));
|
||||
}
|
||||
assert.equal(current.prompts.length, 1);
|
||||
assert.equal(current.sourceReads, 0);
|
||||
}
|
||||
assert.deepEqual(disabled.calls, baseline.calls);
|
||||
assert.deepEqual(disabled.sessions, baseline.sessions);
|
||||
assert.deepEqual(disabled.seen, baseline.seen);
|
||||
});
|
||||
|
||||
test(`${channel}: filtered and duplicate events never assemble source metadata`, async () => {
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
|
||||
const rejected = current.event(1, 'ignored', { kind: channel === 'weixin' ? 'direct' : 'group', poisonName: true });
|
||||
if (TEXT_BRIDGES[channel]) rejected.addressed = false;
|
||||
else if (channel === 'wecom') rejected.body.chattype = 'unknown';
|
||||
else if (channel === 'weixin') rejected.from_user_id = 'not-the-owner';
|
||||
else if (channel === 'feishu') rejected.sender.sender_type = 'bot';
|
||||
else if (channel === 'dingtalk') rejected.isInAtList = false;
|
||||
else rejected.rawEventType = 'GROUP_MESSAGE_CREATE';
|
||||
await current.bridge.accept(rejected);
|
||||
assert.equal(current.prompts.length, 0);
|
||||
assert.equal(current.sourceReads, 0);
|
||||
await current.bridge.accept(current.event(2, 'accepted'));
|
||||
const reads = current.sourceReads;
|
||||
await current.bridge.accept(current.event(2, 'duplicate', { poisonName: true }));
|
||||
assert.equal(current.prompts.length, 1);
|
||||
assert.equal(current.sourceReads, reads);
|
||||
});
|
||||
|
||||
test(`${channel}: field subsets and guidance-only/empty configurations do not restore defaults`, async () => {
|
||||
for (const config of [settings({ fields: ['channel'] }), settings({ fields: [], guidance: 'custom' }), settings({ fields: [], guidance: '' })]) {
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, config) });
|
||||
await current.bridge.accept(current.event(1, 'hello'));
|
||||
if (config.fields.length) assert.deepEqual(sourceOf(current.prompts[0]), { channel });
|
||||
else if (config.guidance) assert.equal(current.prompts[0], '<dsh_im_source_guidance>\ncustom\n</dsh_im_source_guidance>\n\nhello');
|
||||
else assert.equal(current.prompts[0], 'hello');
|
||||
}
|
||||
});
|
||||
|
||||
test(`${channel}: local help/new/steer do not inspect enhancement-only sources`, async () => {
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
|
||||
for (const [index, text] of ['/help', '/new', '/steer supplement'].entries()) {
|
||||
await current.bridge.accept(current.event(index + 1, text, { poisonName: true }));
|
||||
}
|
||||
assert.equal(current.prompts.length, 0);
|
||||
assert.equal(current.sourceReads, 0);
|
||||
assert.doesNotMatch(JSON.stringify(current.calls), /<dsh_im_source/);
|
||||
});
|
||||
|
||||
for (const kind of ['question', 'approval']) {
|
||||
test(`${channel}: ${kind} replies remain unenhanced while ordinary messages are enabled`, async () => {
|
||||
const finished = deferred();
|
||||
const responses = [];
|
||||
const current = fixture(channel, {
|
||||
contextEnhancement: provider(channel, settings()),
|
||||
onAsk: async ({ sessionId, options }) => {
|
||||
await options.onInteraction({
|
||||
kind, interactionId: 'interaction', rpcId: 'interaction', sessionId,
|
||||
payload: kind === 'question'
|
||||
? { type: 'question/requested', sessionId, questions: [{ id: 'answer', question: 'waiting for answer' }] }
|
||||
: { type: 'approval/requested', sessionId, approvalId: 'interaction', toolName: 'fixture-tool', callId: 'call', reason: 'waiting for approval' },
|
||||
toolCall: kind === 'approval' ? { callId: 'call', name: 'fixture-tool', arguments: '{}' } : undefined,
|
||||
respond: async (result) => { responses.push(result); finished.resolve(); return { accepted: true }; },
|
||||
});
|
||||
await finished.promise;
|
||||
return 'answer unchanged';
|
||||
},
|
||||
});
|
||||
const processing = current.bridge.accept(current.event(1, 'ordinary question'));
|
||||
await eventually(() => JSON.stringify(current.calls).includes('waiting for'));
|
||||
const readsBeforeReply = current.sourceReads;
|
||||
await current.bridge.accept(current.event(2, kind === 'question' ? 'blue' : '批准', { poisonName: true }));
|
||||
await processing;
|
||||
assert.equal(current.prompts.length, 1);
|
||||
assert.equal(responses.length, 1);
|
||||
assert.equal(current.sourceReads, readsBeforeReply);
|
||||
assert.doesNotMatch(JSON.stringify(responses), /dsh_im_source/);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('Telegram retains event first/last names and falls back only to an existing username', async () => {
|
||||
const current = fixture('telegram', { contextEnhancement: provider('telegram', settings()) });
|
||||
for (const [index, from] of [
|
||||
{ first_name: 'Ada', last_name: 'Lovelace', username: 'ada' },
|
||||
{ first_name: '', last_name: ' ', username: 'ada' },
|
||||
{},
|
||||
].entries()) {
|
||||
const message = normalizeTelegramUpdate({ update_id: index + 1, message: {
|
||||
message_id: index + 1, chat: { id: 100, type: 'private' }, from: { id: 42, ...from }, text: 'hello',
|
||||
} }, { botId: 'bot', username: 'testbot' });
|
||||
await current.bridge.accept(message);
|
||||
}
|
||||
assert.equal(sourceOf(current.prompts[0]).senderName, 'Ada Lovelace');
|
||||
assert.equal(sourceOf(current.prompts[1]).senderName, 'ada');
|
||||
assert.equal(Object.hasOwn(sourceOf(current.prompts[2]), 'senderName'), false);
|
||||
});
|
||||
|
||||
test('Discord prefers event member nickname, global name, then username without a profile lookup', async () => {
|
||||
const current = fixture('discord', { contextEnhancement: provider('discord', settings()) });
|
||||
for (const [index, names] of [
|
||||
{ member: { nick: 'Group Nick' }, author: { global_name: 'Global Name', username: 'username' } },
|
||||
{ member: { nick: '' }, author: { global_name: 'Global Name', username: 'username' } },
|
||||
{ author: { global_name: null, username: 'username' } },
|
||||
{ author: {} },
|
||||
].entries()) {
|
||||
await current.bridge.accept(normalizeDiscordMessage({
|
||||
...names, id: String(index + 1), channel_id: 'chat', guild_id: 'guild',
|
||||
author: { id: 'actor', ...names.author }, content: 'hello', mentions: [{ id: 'bot' }],
|
||||
}, 'bot'));
|
||||
}
|
||||
assert.deepEqual(current.prompts.map((content) => sourceOf(content).senderName),
|
||||
['Group Nick', 'Global Name', 'username', undefined]);
|
||||
});
|
||||
96
test/channels/shared/context-enhancement-production.test.mjs
Normal file
96
test/channels/shared/context-enhancement-production.test.mjs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { BotWorkspaceStore } from '../../../src/channels/shared/bot-workspace-store.mjs';
|
||||
import {
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
captureContextEnhancement,
|
||||
} from '../../../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
for (const channel of ['wecom', 'weixin', 'feishu', 'dingtalk', 'qq', 'slack', 'telegram', 'discord', 'whatsapp']) {
|
||||
test(`${channel} production passes live, isolated context settings without reconnecting or changing Harness options`, async (t) => {
|
||||
const dataDir = await mkdtemp(join(tmpdir(), `dsh-context-${channel}-`));
|
||||
t.after(() => rm(dataDir, { recursive: true, force: true }));
|
||||
const { createProductionController } = await import(`../../../plugin-src/host/channels/${channel}/production.mjs`);
|
||||
const workspaces = await new BotWorkspaceStore(join(dataDir, 'workspaces.json'), { defaultWorkspace: dataDir }).load();
|
||||
const botIds = [`${channel}_one`, `${channel}_two`];
|
||||
const bots = botIds.map((botId) => ({ botId, id: botId, appId: 'cli_fixture', secretRef: 'fixture-only' }));
|
||||
const runtimes = [];
|
||||
let controllerOptions;
|
||||
let harnessOptions;
|
||||
let stateLoads = 0;
|
||||
class ConfigStore {
|
||||
async load() { return this; }
|
||||
list() { return bots; }
|
||||
}
|
||||
class StateStore {
|
||||
async load() { stateLoads += 1; return this; }
|
||||
clearSessions() { throw new Error('enhancement must not reset sessions'); }
|
||||
}
|
||||
class Harness {
|
||||
constructor(options) { harnessOptions = options; }
|
||||
stopManagedProcess() {}
|
||||
}
|
||||
class Runtime {
|
||||
constructor(options) { runtimes.push(options); }
|
||||
}
|
||||
class Controller {
|
||||
constructor(options) { controllerOptions = options; }
|
||||
status() { return { bots: botIds.map((botId) => ({ botId, connected: true })) }; }
|
||||
reconnectBot() { throw new Error('enhancement must not reconnect'); }
|
||||
async close() {}
|
||||
}
|
||||
const production = await createProductionController({
|
||||
credentials: {},
|
||||
apiProxy: {},
|
||||
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
}, { dataDir, workspace: dataDir }, {
|
||||
ConfigStore,
|
||||
StateStore,
|
||||
HarnessClient: Harness,
|
||||
Controller,
|
||||
Runtime,
|
||||
FeishuRuntime: Runtime,
|
||||
workspaces,
|
||||
api: {},
|
||||
qrAuth: {},
|
||||
deviceAuth: {},
|
||||
lark: {},
|
||||
proxyEnv: {},
|
||||
createConnectionSupervisor: () => ({
|
||||
ready: Promise.resolve(), start() { return this; }, async close() {},
|
||||
}),
|
||||
});
|
||||
t.after(() => production.close());
|
||||
for (const bot of bots) {
|
||||
await controllerOptions.createRuntime({ botId: bot.botId, config: bot });
|
||||
}
|
||||
assert.equal(runtimes.length, 2);
|
||||
assert.equal(stateLoads, 2);
|
||||
assert.equal(Object.hasOwn(harnessOptions, 'contextEnhancement'), false);
|
||||
assert.equal(Object.hasOwn(harnessOptions, 'source'), false);
|
||||
const provider = runtimes[0].contextEnhancement;
|
||||
assert.equal(provider.botId, botIds[0]);
|
||||
assert.equal(provider.getSettings(), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(captureContextEnhancement(provider, 'direct'), null);
|
||||
const generation = workspaces.generationFor(botIds[0]);
|
||||
const selected = { groupEnabled: false, directEnabled: true, fields: [], guidance: 'runtime update' };
|
||||
const saved = await production.controller.updateContextEnhancement(botIds[0], selected);
|
||||
assert.deepEqual(saved.bots[0].contextEnhancement, selected);
|
||||
assert.deepEqual(provider.getSettings(), selected);
|
||||
const beforeDisable = captureContextEnhancement(provider, 'direct');
|
||||
assert.deepEqual(beforeDisable.config, selected);
|
||||
assert.equal(runtimes[0].contextEnhancement, provider, 'runtime does not need reconstruction');
|
||||
assert.equal(runtimes[1].contextEnhancement.getSettings(), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(runtimes[1].contextEnhancement.botId, botIds[1]);
|
||||
await production.controller.updateContextEnhancement(botIds[0], { ...selected, directEnabled: false });
|
||||
assert.equal(captureContextEnhancement(provider, 'direct'), null);
|
||||
assert.equal(beforeDisable.config.directEnabled, true, 'an already received message keeps its snapshot');
|
||||
assert.equal(workspaces.generationFor(botIds[0]), generation);
|
||||
assert.equal(runtimes.length, 2);
|
||||
assert.equal(stateLoads, 2);
|
||||
});
|
||||
}
|
||||
|
|
@ -1515,6 +1515,125 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
|
|||
}
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'two private messages', kinds: ['private', 'private'], before: [true, true], after: [false, false] },
|
||||
{ name: 'mixed private/group messages', kinds: ['private', 'supergroup'], before: [false, true], after: [true, false] },
|
||||
{ name: 'disabled private messages before enabling', kinds: ['private', 'private'], before: [false, false], after: [true, true] },
|
||||
]) {
|
||||
test(`Telegram received poll batch retains its settings across cursor writes: ${scenario.name}`, async () => {
|
||||
const firstCursorStarted = deferred();
|
||||
const releaseFirstCursor = deferred();
|
||||
const allPrompts = deferred();
|
||||
const seen = new Set();
|
||||
const asked = [];
|
||||
const pollOffsets = [];
|
||||
const cursorWrites = [];
|
||||
let cursor = 10;
|
||||
let settingsReads = 0;
|
||||
let nextMessageId = 500;
|
||||
const configFor = ([directEnabled, groupEnabled], guidance) => ({
|
||||
directEnabled, groupEnabled, fields: ['channel', 'botId'], guidance,
|
||||
});
|
||||
let config = configFor(scenario.before, 'before cursor write');
|
||||
const update = (id, kind) => ({
|
||||
update_id: id,
|
||||
message: {
|
||||
message_id: id + 100,
|
||||
chat: { id: kind === 'private' ? 42 : -1001, type: kind },
|
||||
from: { id: 7, is_bot: false, first_name: 'Ada' },
|
||||
text: kind === 'private' ? `message ${id}` : `@HarnessBot message ${id}`,
|
||||
entities: kind === 'private' ? [] : [{ type: 'mention', offset: 0, length: 11 }],
|
||||
},
|
||||
});
|
||||
const firstBatch = scenario.kinds.map((kind, index) => update(10 + index, kind));
|
||||
const nextBatch = ['private', 'supergroup'].map((kind, index) => update(12 + index, kind));
|
||||
const runtime = new TelegramRuntime({
|
||||
config: { botId: 'telegram_poll', platformId: '123456789', username: 'HarnessBot' },
|
||||
token: TOKEN,
|
||||
contextEnhancement: {
|
||||
botId: 'telegram_poll',
|
||||
getSettings: () => { settingsReads += 1; return config; },
|
||||
},
|
||||
state: {
|
||||
cursor: () => cursor,
|
||||
setCursor: async (value) => {
|
||||
cursorWrites.push(value);
|
||||
if (value === 11) {
|
||||
firstCursorStarted.resolve();
|
||||
await releaseFirstCursor.promise;
|
||||
}
|
||||
cursor = value;
|
||||
},
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-existing',
|
||||
},
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => {
|
||||
asked.push(text);
|
||||
if (asked.length === 4) allPrompts.resolve();
|
||||
return 'done';
|
||||
},
|
||||
},
|
||||
createApi: () => ({
|
||||
getMe: async () => ({ id: 123456789, is_bot: true }),
|
||||
getWebhookInfo: async () => ({ url: '' }),
|
||||
setMyCommands: async () => true,
|
||||
setChatMenuButton: async () => true,
|
||||
getUpdates: async ({ offset, signal }) => {
|
||||
pollOffsets.push(offset);
|
||||
if (offset === 10) return firstBatch;
|
||||
if (offset === 12) return nextBatch;
|
||||
assert.equal(offset, 14);
|
||||
return new Promise((_, reject) => {
|
||||
if (signal.aborted) return reject(signal.reason);
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
});
|
||||
},
|
||||
setMessageReaction: async () => true,
|
||||
sendChatAction: async () => true,
|
||||
sendRichMessageDraft: async () => true,
|
||||
sendRichMessage: async () => ({ message_id: nextMessageId++ }),
|
||||
sendMessage: async () => ({ message_id: nextMessageId++ }),
|
||||
editMessageText: async () => true,
|
||||
}),
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
try {
|
||||
await runtime.start();
|
||||
await bounded(firstCursorStarted.promise, 'first cursor write was not reached', 5_000);
|
||||
assert.equal(settingsReads, 2, 'every received update captures its settings before the first cursor await');
|
||||
config = configFor(scenario.after, 'after cursor write');
|
||||
releaseFirstCursor.resolve();
|
||||
await bounded(allPrompts.promise, 'both poll batches did not reach Harness', 5_000);
|
||||
for (const [index, event] of [...firstBatch, ...nextBatch].entries()) {
|
||||
const expectedText = `message ${event.update_id}`;
|
||||
const content = asked.find((text) => text.endsWith(expectedText));
|
||||
assert.ok(content, `Missing prompt ${event.update_id}`);
|
||||
const switches = index < 2 ? scenario.before : scenario.after;
|
||||
const enabled = switches[event.message.chat.type === 'private' ? 0 : 1];
|
||||
if (enabled) {
|
||||
assert.match(content, index < 2 ? /before cursor write/ : /after cursor write/);
|
||||
assert.deepEqual(JSON.parse(/^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(content)[1]), {
|
||||
channel: 'telegram', botId: 'telegram_poll',
|
||||
});
|
||||
} else {
|
||||
assert.equal(content, expectedText);
|
||||
}
|
||||
}
|
||||
assert.equal(settingsReads, 4, 'Bridge reuses the snapshot instead of reading settings again');
|
||||
assert.deepEqual(pollOffsets, [10, 12, 14]);
|
||||
assert.deepEqual(cursorWrites, [11, 12, 13, 14]);
|
||||
assert.deepEqual([...seen].sort(), ['10', '11', '12', '13']);
|
||||
} finally {
|
||||
releaseFirstCursor.resolve();
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('Telegram runtime keeps polling while a Harness question waits for its answer', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-interaction-'));
|
||||
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
|
||||
|
|
|
|||
521
test/context-enhancement-settings.test.mjs
Normal file
521
test/context-enhancement-settings.test.mjs
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
BotWorkspaceStore,
|
||||
createWorkspaceAwareController,
|
||||
observeBotWorkspaceRemovals,
|
||||
} from '../src/channels/shared/bot-workspace-store.mjs';
|
||||
import {
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
captureContextEnhancement,
|
||||
enhanceContextContent,
|
||||
} from '../src/channels/shared/context-enhancement.mjs';
|
||||
import { TokenBotController } from '../src/channels/shared/token-bot-controller.mjs';
|
||||
import {
|
||||
TokenBotConfigStore,
|
||||
deriveTokenBotIdentity,
|
||||
} from '../src/channels/shared/token-config-store.mjs';
|
||||
import { createWecomRpcHandler, WECOM_ENDPOINTS } from '../plugin-src/host/channels/wecom/rpc.mjs';
|
||||
import { createWeixinRpcHandler, WEIXIN_ENDPOINTS } from '../plugin-src/host/channels/weixin/rpc.mjs';
|
||||
import { createFeishuRpcHandler, FEISHU_ENDPOINTS } from '../plugin-src/host/channels/feishu/rpc.mjs';
|
||||
import { createDingtalkRpcHandler, DINGTALK_ENDPOINTS } from '../plugin-src/host/channels/dingtalk/rpc.mjs';
|
||||
import { createQqRpcHandler, QQ_ENDPOINTS } from '../plugin-src/host/channels/qq/rpc.mjs';
|
||||
import { createSlackRpcHandler, SLACK_ENDPOINTS } from '../plugin-src/host/channels/slack/rpc.mjs';
|
||||
import { createTelegramRpcHandler, TELEGRAM_ENDPOINTS } from '../plugin-src/host/channels/telegram/rpc.mjs';
|
||||
import { createDiscordRpcHandler, DISCORD_ENDPOINTS } from '../plugin-src/host/channels/discord/rpc.mjs';
|
||||
import { createWhatsappRpcHandler, WHATSAPP_ENDPOINTS } from '../plugin-src/host/channels/whatsapp/rpc.mjs';
|
||||
import {
|
||||
SET_CONTEXT_ENHANCEMENT_ENDPOINT,
|
||||
validContextEnhancementPayload,
|
||||
} from '../plugin-src/host/channels/shared/context-enhancement-rpc.mjs';
|
||||
|
||||
const CHANNELS = [
|
||||
['wecom', createWecomRpcHandler, WECOM_ENDPOINTS],
|
||||
['weixin', createWeixinRpcHandler, WEIXIN_ENDPOINTS],
|
||||
['feishu', createFeishuRpcHandler, FEISHU_ENDPOINTS],
|
||||
['dingtalk', createDingtalkRpcHandler, DINGTALK_ENDPOINTS],
|
||||
['qq', createQqRpcHandler, QQ_ENDPOINTS],
|
||||
['slack', createSlackRpcHandler, SLACK_ENDPOINTS],
|
||||
['telegram', createTelegramRpcHandler, TELEGRAM_ENDPOINTS],
|
||||
['discord', createDiscordRpcHandler, DISCORD_ENDPOINTS],
|
||||
['whatsapp', createWhatsappRpcHandler, WHATSAPP_ENDPOINTS],
|
||||
];
|
||||
|
||||
const enabled = (overrides = {}) => ({
|
||||
groupEnabled: true, directEnabled: true, fields: ['channel', 'botId'], guidance: 'saved guidance',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-context-settings-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const path = join(directory, 'workspaces.json');
|
||||
const store = await new BotWorkspaceStore(path, { defaultWorkspace: directory }).load();
|
||||
return { directory, path, store };
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function fakeController(botIds) {
|
||||
const unavailable = () => { throw new Error('unrelated lifecycle action must not run'); };
|
||||
return {
|
||||
status: () => ({ bots: botIds.map((botId) => ({ botId, configured: true, connected: false })) }),
|
||||
startProvisioning: unavailable,
|
||||
registrationStatus: unavailable,
|
||||
submitVerification: unavailable,
|
||||
cancelProvisioning: unavailable,
|
||||
bindCredentials: unavailable,
|
||||
reconnectBot: unavailable,
|
||||
deleteBot: unavailable,
|
||||
startRegistration: unavailable,
|
||||
cancelRegistration: unavailable,
|
||||
disconnect: unavailable,
|
||||
setAccessPolicy: unavailable,
|
||||
approveSender: unavailable,
|
||||
revokeSender: unavailable,
|
||||
};
|
||||
}
|
||||
|
||||
function awareController(store, core, extra = {}) {
|
||||
return createWorkspaceAwareController(core, {
|
||||
workspaces: store,
|
||||
stateFor: () => { throw new Error('saving enhancement must not load or clear session state'); },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
test('old workspace files remain untouched and default enhancement off', async (t) => {
|
||||
const { directory, path } = await fixture(t);
|
||||
const original = JSON.stringify({ version: 1, workspaces: { old_bot: directory }, agentPresets: { old_bot: 'preset-one' } });
|
||||
await writeFile(path, original);
|
||||
const store = await new BotWorkspaceStore(path).load();
|
||||
assert.equal(store.contextEnhancementFor('old_bot'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(store.contextEnhancementFor('unknown_bot'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(store.workspaceFor('old_bot'), directory);
|
||||
assert.equal(store.agentPresetFor('old_bot'), 'preset-one');
|
||||
assert.equal(store.decorateStatus({ bots: [{ botId: 'old_bot' }] }).bots[0].contextEnhancement, DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(await readFile(path, 'utf8'), original, 'reading/decorating old config must not migrate/write it');
|
||||
});
|
||||
|
||||
test('complete per-bot settings persist all switch combinations and explicit empty fields/text', async (t) => {
|
||||
const { path, directory, store } = await fixture(t);
|
||||
await store.ensure('bot_one', { defaultAgentPreset: 'preset-one' });
|
||||
await store.ensure('bot_two');
|
||||
const generation = store.generationFor('bot_one');
|
||||
const incarnation = store.incarnationFor('bot_one');
|
||||
for (const groupEnabled of [false, true]) {
|
||||
for (const directEnabled of [false, true]) {
|
||||
const selected = { groupEnabled, directEnabled, fields: [], guidance: '' };
|
||||
const saved = await store.setContextEnhancement('bot_one', selected);
|
||||
assert.deepEqual(saved, selected);
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), saved);
|
||||
assert.equal(store.contextEnhancementFor('bot_two'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
const reloaded = await new BotWorkspaceStore(path).load();
|
||||
assert.deepEqual(reloaded.contextEnhancementFor('bot_one'), selected);
|
||||
}
|
||||
}
|
||||
assert.equal(store.workspaceFor('bot_one'), directory);
|
||||
assert.equal(store.agentPresetFor('bot_one'), 'preset-one');
|
||||
assert.equal(store.generationFor('bot_one'), generation);
|
||||
assert.equal(store.incarnationFor('bot_one'), incarnation);
|
||||
const document = JSON.parse(await readFile(path, 'utf8'));
|
||||
assert.deepEqual(document.contextEnhancement.bot_one, enabled({ fields: [], guidance: '' }));
|
||||
assert.equal(Object.hasOwn(document.contextEnhancement, 'bot_two'), false);
|
||||
|
||||
const alternate = join(directory, 'alternate');
|
||||
await mkdir(alternate);
|
||||
const before = store.contextEnhancementFor('bot_one');
|
||||
await store.setWorkspace('bot_one', alternate);
|
||||
await store.setAgentPreset('bot_one', 'preset-two');
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), before);
|
||||
});
|
||||
|
||||
test('malformed enhancement entries fail off without poisoning valid workspace or preset data', async (t) => {
|
||||
const { directory, path } = await fixture(t);
|
||||
const malformed = [null, [], true, 'bad', {}, enabled({ fields: ['secret'] }), enabled({ directEnabled: 'yes' })];
|
||||
for (const invalid of malformed) {
|
||||
const original = {
|
||||
version: 1,
|
||||
workspaces: { broken_bot: directory, healthy_bot: directory },
|
||||
agentPresets: { broken_bot: 'preset-one' },
|
||||
contextEnhancement: { broken_bot: invalid, healthy_bot: enabled(), '../bad-id': enabled() },
|
||||
};
|
||||
await writeFile(path, JSON.stringify(original));
|
||||
const store = await new BotWorkspaceStore(path).load();
|
||||
assert.equal(store.contextEnhancementFor('broken_bot'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.deepEqual(store.contextEnhancementFor('healthy_bot'), enabled());
|
||||
assert.equal(store.workspaceFor('broken_bot'), directory);
|
||||
assert.equal(store.agentPresetFor('broken_bot'), 'preset-one');
|
||||
}
|
||||
for (const contextEnhancement of [null, [], 'broken', true, 1]) {
|
||||
await writeFile(path, JSON.stringify({ version: 1, workspaces: { bot_one: directory }, contextEnhancement }));
|
||||
const store = await new BotWorkspaceStore(path).load();
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
}
|
||||
await writeFile(path, JSON.stringify({ version: 1, workspaces: { bot_one: 'relative' }, contextEnhancement: null }));
|
||||
await assert.rejects(new BotWorkspaceStore(path).load(), /workspace config is invalid/);
|
||||
});
|
||||
|
||||
test('pending and failed writes never publish an uncommitted context snapshot', async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
const first = await store.setContextEnhancement('bot_one', enabled());
|
||||
const provider = { botId: 'bot_one', getSettings: () => store.contextEnhancementFor('bot_one') };
|
||||
const queuedMessage = captureContextEnhancement(provider, 'group');
|
||||
const diskBefore = await readFile(path, 'utf8');
|
||||
await mkdir(`${path}.tmp`);
|
||||
const save = store.setContextEnhancement('bot_one', enabled({ fields: [], guidance: '' }));
|
||||
await Promise.resolve();
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), first, 'the persist await must not expose partial settings');
|
||||
await assert.rejects(save);
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), first);
|
||||
assert.equal(await readFile(path, 'utf8'), diskBefore);
|
||||
await rm(`${path}.tmp`, { recursive: true });
|
||||
await store.setContextEnhancement('bot_one', enabled({ groupEnabled: false, directEnabled: false, fields: [], guidance: '' }));
|
||||
assert.equal(captureContextEnhancement(provider, 'group'), null);
|
||||
const text = enhanceContextContent('queued text', queuedMessage, () => ({ channel: 'qq' }));
|
||||
assert.match(text, /saved guidance/);
|
||||
assert.match(text, /"botId":"bot_one"/);
|
||||
assert.equal(Object.isFrozen(first), true);
|
||||
assert.equal(Object.isFrozen(first.fields), true);
|
||||
});
|
||||
|
||||
test('invalid saves reject atomically and deleted or rebound bot incarnations cannot receive stale updates', async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
const initial = await store.setContextEnhancement('bot_one', enabled());
|
||||
const diskBefore = await readFile(path, 'utf8');
|
||||
await assert.rejects(store.setContextEnhancement('bot_one', enabled({ fields: ['token'] })), { code: 'context-enhancement-invalid' });
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), initial);
|
||||
assert.equal(await readFile(path, 'utf8'), diskBefore);
|
||||
|
||||
const oldIncarnation = store.incarnationFor('bot_one');
|
||||
await store.remove('bot_one');
|
||||
await store.ensure('bot_one');
|
||||
await assert.rejects(store.setContextEnhancement('bot_one', enabled(), { incarnation: oldIncarnation }), { code: 'workspace-bot-not-found' });
|
||||
await assert.rejects(store.setContextEnhancement('bot_one', enabled(), { incarnation: null }), { code: 'workspace-bot-not-found' });
|
||||
await assert.rejects(store.setContextEnhancement('missing_bot', enabled()), { code: 'workspace-bot-not-found' });
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
});
|
||||
|
||||
test('context update captures incarnation before waiting on controller status', async (t) => {
|
||||
const { store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
const statusStarted = deferred();
|
||||
const statusResult = deferred();
|
||||
const controller = awareController(store, {
|
||||
async status() { statusStarted.resolve(); return statusResult.promise; },
|
||||
});
|
||||
const pending = controller.updateContextEnhancement('bot_one', enabled());
|
||||
await statusStarted.promise;
|
||||
await store.remove('bot_one');
|
||||
await store.ensure('bot_one');
|
||||
statusResult.resolve({ bots: [{ botId: 'bot_one' }] });
|
||||
await assert.rejects(pending, { code: 'workspace-bot-not-found' });
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
});
|
||||
|
||||
test('removal abort preserves settings, committed deletion removes them, and reconciliation cleans orphans', async (t) => {
|
||||
const { directory, path, store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
await store.ensure('bot_two');
|
||||
const saved = await store.setContextEnhancement('bot_one', enabled({ fields: [], guidance: '' }));
|
||||
const removal = await store.beginRemoval('bot_one');
|
||||
await store.abortRemoval(removal);
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), saved);
|
||||
const observed = observeBotWorkspaceRemovals({ async remove(botId) { return { botId }; } }, { workspaces: store });
|
||||
await observed.remove('bot_one');
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
const remaining = JSON.parse(await readFile(path, 'utf8'));
|
||||
assert.equal(remaining.contextEnhancement, undefined);
|
||||
assert.deepEqual(remaining.workspaces, { bot_two: directory });
|
||||
|
||||
await writeFile(path, JSON.stringify({
|
||||
version: 1, workspaces: { bot_two: directory }, contextEnhancement: { orphan_bot: enabled() },
|
||||
}));
|
||||
const reloaded = await new BotWorkspaceStore(path).load();
|
||||
await reloaded.reconcile(['bot_two']);
|
||||
assert.equal(JSON.parse(await readFile(path, 'utf8')).contextEnhancement, undefined);
|
||||
await reloaded.remove('bot_two');
|
||||
await assert.rejects(readFile(path), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
test('failed cleanup retires context settings and startup reconciliation heals stale disk state', async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
await store.ensure('bot_two');
|
||||
await store.setContextEnhancement('bot_one', enabled());
|
||||
await mkdir(`${path}.tmp`);
|
||||
await assert.rejects(store.remove('bot_one'));
|
||||
assert.equal(store.has('bot_one'), false);
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
await rm(`${path}.tmp`, { recursive: true });
|
||||
const reloaded = await new BotWorkspaceStore(path).load();
|
||||
assert.equal(reloaded.contextEnhancementFor('bot_one').groupEnabled, true);
|
||||
await reloaded.reconcile(['bot_two']);
|
||||
assert.equal(reloaded.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(JSON.parse(await readFile(path, 'utf8')).contextEnhancement, undefined);
|
||||
});
|
||||
|
||||
for (const recovery of ['retry', 'restart']) {
|
||||
test(`failed same-id rebind rolls back credentials and cannot revive old context after ${recovery}`, async (t) => {
|
||||
const { directory, path, store } = await fixture(t);
|
||||
const configPath = join(directory, 'bots.json');
|
||||
const identityOptions = { channel: 'test', botPrefix: 'bot', tokenRefPrefix: 'BOT_TOKEN' };
|
||||
const configStore = await new TokenBotConfigStore(configPath, identityOptions).load();
|
||||
const credentials = new Map();
|
||||
const runtimeStarts = [];
|
||||
const controllers = [];
|
||||
const createController = (workspaces, configs) => {
|
||||
const core = new TokenBotController({
|
||||
descriptor: { key: 'test', label: 'Test', connectionLabel: 'connection' },
|
||||
configStore: observeBotWorkspaceRemovals(configs, { workspaces }),
|
||||
credentials: {
|
||||
async resolve(ref) { return credentials.has(ref) ? { value: credentials.get(ref) } : undefined; },
|
||||
async set(ref, value) { credentials.set(ref, value); },
|
||||
async unset(ref) { credentials.delete(ref); },
|
||||
},
|
||||
async inspectToken(token) { return { platformId: token.split(':')[0], name: 'Test bot' }; },
|
||||
deriveIdentity: (id) => deriveTokenBotIdentity(id, identityOptions),
|
||||
maskPlatformId: (id) => id,
|
||||
async createRuntime({ botId }) {
|
||||
runtimeStarts.push(botId);
|
||||
await workspaces.ensure(botId);
|
||||
return {
|
||||
status: { ready: true, connectionState: 'connected', harnessReachable: true },
|
||||
async start() {},
|
||||
async stop() {},
|
||||
};
|
||||
},
|
||||
logger: { warn() {} },
|
||||
});
|
||||
const controller = createWorkspaceAwareController(core, {
|
||||
workspaces,
|
||||
stateFor: async () => ({ async clearSessions() {} }),
|
||||
});
|
||||
controllers.push(controller);
|
||||
return controller;
|
||||
};
|
||||
t.after(async () => { await Promise.all(controllers.map((controller) => controller.close())); });
|
||||
let controller = createController(store, configStore);
|
||||
await controller.bindCredentials({ token: 'one:original' });
|
||||
await controller.bindCredentials({ token: 'two:original' });
|
||||
const first = deriveTokenBotIdentity('one', identityOptions);
|
||||
const second = deriveTokenBotIdentity('two', identityOptions);
|
||||
const oldWorkspace = join(directory, 'old-workspace');
|
||||
await mkdir(oldWorkspace);
|
||||
await store.setWorkspace(first.botId, oldWorkspace);
|
||||
await store.setAgentPreset(first.botId, 'old-preset');
|
||||
await store.setContextEnhancement(first.botId, enabled());
|
||||
const otherSettings = await store.setContextEnhancement(second.botId, enabled({ guidance: 'other bot' }));
|
||||
const originalDocument = await readFile(path, 'utf8');
|
||||
const otherGeneration = store.generationFor(second.botId);
|
||||
|
||||
// Only metadata persistence fails; the independent bot config remains writable.
|
||||
await mkdir(`${path}.tmp`);
|
||||
await controller.deleteBot(first.botId);
|
||||
assert.equal(configStore.get(first.botId), null);
|
||||
assert.equal(store.contextEnhancementFor(first.botId), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(await readFile(path, 'utf8'), originalDocument);
|
||||
const previousStarts = runtimeStarts.length;
|
||||
await assert.rejects(controller.bindCredentials({ token: 'one:rebound' }));
|
||||
assert.equal(configStore.get(first.botId), null, 'failed cleanup must precede the new config commit');
|
||||
assert.equal(credentials.has(first.tokenRef), false, 'the existing config-save catch rolls back the new credential');
|
||||
assert.equal(runtimeStarts.length, previousStarts, 'a blocked rebind must not attempt runtime startup');
|
||||
assert.equal(await readFile(path, 'utf8'), originalDocument);
|
||||
assert.equal(store.contextEnhancementFor(second.botId), otherSettings);
|
||||
assert.equal(store.generationFor(second.botId), otherGeneration);
|
||||
const reloadedConfigs = await new TokenBotConfigStore(configPath, identityOptions).load();
|
||||
assert.equal(reloadedConfigs.get(first.botId), null, 'restart has no active config for the deleted id');
|
||||
|
||||
await rm(`${path}.tmp`, { recursive: true });
|
||||
let currentStore = store;
|
||||
if (recovery === 'restart') {
|
||||
await controller.close();
|
||||
currentStore = await new BotWorkspaceStore(path, { defaultWorkspace: directory }).load();
|
||||
await currentStore.reconcile(reloadedConfigs.list().map((bot) => bot.botId));
|
||||
assert.equal(currentStore.contextEnhancementFor(first.botId), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
controller = createController(currentStore, reloadedConfigs);
|
||||
}
|
||||
const rebound = await controller.bindCredentials({ token: 'one:rebound' });
|
||||
assert.equal(rebound.bots.find((bot) => bot.botId === first.botId).connected, true);
|
||||
assert.equal(currentStore.workspaceFor(first.botId), directory);
|
||||
assert.equal(currentStore.agentPresetFor(first.botId), null);
|
||||
assert.equal(currentStore.contextEnhancementFor(first.botId), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(credentials.get(first.tokenRef), 'one:rebound');
|
||||
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace: directory }).load();
|
||||
assert.equal(reloaded.workspaceFor(first.botId), directory);
|
||||
assert.equal(reloaded.agentPresetFor(first.botId), null);
|
||||
assert.equal(reloaded.contextEnhancementFor(first.botId), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.deepEqual(reloaded.contextEnhancementFor(second.botId), otherSettings);
|
||||
});
|
||||
}
|
||||
|
||||
for (const idKey of ['botId', 'id']) {
|
||||
test(`${idKey} config-save observation retries only dirty IDs and preserves normal save timing/results`, async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
await store.ensure('bot_two');
|
||||
await store.setContextEnhancement('bot_one', enabled());
|
||||
const saveMethod = idKey === 'id' ? 'saveBot' : 'save';
|
||||
const method = idKey === 'id' ? 'removeBot' : 'remove';
|
||||
const result = Promise.resolve('saved');
|
||||
const calls = [];
|
||||
const target = {
|
||||
[saveMethod](config) { assert.equal(this, target); calls.push(config); return result; },
|
||||
async [method](botId) { return { [idKey]: botId }; },
|
||||
};
|
||||
const observed = observeBotWorkspaceRemovals(target, {
|
||||
workspaces: store,
|
||||
method,
|
||||
botIdFromRemoved: (removed) => removed[idKey],
|
||||
...(idKey === 'id' ? { saveMethod, botIdFromSave: (config) => config.id } : {}),
|
||||
});
|
||||
assert.equal(observed[saveMethod]({ [idKey]: 'bot_two' }), result);
|
||||
assert.equal(calls.length, 1, 'normal save starts synchronously, without a new queue/await');
|
||||
await mkdir(`${path}.tmp`);
|
||||
await observed[method]('bot_one');
|
||||
await assert.rejects(observed[saveMethod]({ [idKey]: 'bot_one' }));
|
||||
assert.equal(calls.length, 1, 'failed metadata cleanup prevents config save');
|
||||
assert.equal(observed[saveMethod]({ [idKey]: 'bot_two' }), result);
|
||||
assert.equal(calls.length, 2, 'another bot is not blocked by this retired id');
|
||||
await rm(`${path}.tmp`, { recursive: true });
|
||||
assert.equal(await observed[saveMethod]({ [idKey]: 'bot_one' }), 'saved');
|
||||
assert.equal(calls.length, 3);
|
||||
const document = JSON.parse(await readFile(path, 'utf8'));
|
||||
assert.equal(Object.hasOwn(document.workspaces, 'bot_one'), false);
|
||||
assert.equal(Object.hasOwn(document.workspaces, 'bot_two'), true);
|
||||
assert.equal(document.contextEnhancement, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('context save performs no session/lifecycle work and no fallible status/catalog I/O after commit', async (t) => {
|
||||
const { store } = await fixture(t);
|
||||
await store.ensure('bot_one');
|
||||
let statusReads = 0;
|
||||
let catalogReads = 0;
|
||||
const controller = awareController(store, {
|
||||
status() {
|
||||
statusReads += 1;
|
||||
assert.equal(statusReads, 1, 'must not make a second status call after persistence');
|
||||
return { bots: [{ botId: 'bot_one' }] };
|
||||
},
|
||||
}, {
|
||||
agentPresetCatalog() {
|
||||
catalogReads += 1;
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
return { items: [{ id: 'preset-one', label: 'One' }] };
|
||||
},
|
||||
});
|
||||
const result = await controller.updateContextEnhancement('bot_one', enabled());
|
||||
assert.deepEqual(result.bots[0].contextEnhancement, enabled());
|
||||
assert.equal(result.agentPresetCatalog.items[0].id, 'preset-one');
|
||||
assert.equal(catalogReads, 1);
|
||||
|
||||
const brokenCatalog = awareController(store, fakeController(['bot_one']), {
|
||||
agentPresetCatalog() { throw new Error('catalog unavailable'); },
|
||||
});
|
||||
await assert.rejects(brokenCatalog.updateContextEnhancement('bot_one', enabled({ fields: [] })), /catalog unavailable/);
|
||||
assert.deepEqual(store.contextEnhancementFor('bot_one'), enabled());
|
||||
});
|
||||
|
||||
test('shared context RPC payload validator rejects unknown keys and incomplete settings', () => {
|
||||
assert.equal(validContextEnhancementPayload({ botId: 'bot_one', config: enabled() }), true);
|
||||
for (const payload of [
|
||||
null, [], {}, { botId: 'bot_one' }, { botId: '../bot_one', config: enabled() },
|
||||
{ botId: 'bot_one', config: enabled(), token: 'no' },
|
||||
{ botId: 'bot_one', config: { groupEnabled: true } },
|
||||
{ botId: 'bot_one', config: enabled({ fields: ['password'] }) },
|
||||
]) assert.equal(validContextEnhancementPayload(payload), false);
|
||||
});
|
||||
|
||||
for (const [channel, createHandler, endpoints] of CHANNELS.filter(([name]) => (
|
||||
!['slack', 'telegram', 'discord'].includes(name)
|
||||
))) {
|
||||
test(`${channel} QR/status projection failure happens before a context save commits`, async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
const botId = `${channel}_projection`;
|
||||
await store.ensure(botId);
|
||||
const previous = store.contextEnhancementFor(botId);
|
||||
const diskBefore = await readFile(path, 'utf8');
|
||||
const core = fakeController([botId]);
|
||||
core.status = () => ({
|
||||
bots: [{ botId, configured: true }],
|
||||
provisioning: {
|
||||
verificationUrl: 'https://example.test/qr', qrValue: 'whatsapp-qr',
|
||||
},
|
||||
registration: {
|
||||
state: 'qr_ready', attempt: 1, qrCodeUrl: 'https://accounts.feishu.cn/fixture',
|
||||
},
|
||||
});
|
||||
let projections = 0;
|
||||
const handler = createHandler(awareController(store, core), {
|
||||
async encodeQr() {
|
||||
projections += 1;
|
||||
assert.equal(store.contextEnhancementFor(botId), previous);
|
||||
throw new Error('QR rendering unavailable');
|
||||
},
|
||||
});
|
||||
const result = await handler(endpoints.setContextEnhancement, { botId, config: enabled() });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(projections, 1);
|
||||
assert.equal(store.contextEnhancementFor(botId), previous);
|
||||
assert.equal(await readFile(path, 'utf8'), diskBefore);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [channel, createHandler, endpoints] of CHANNELS) {
|
||||
test(`${channel} context RPC returns its complete status, preserves empty settings and rejects invalid saves`, async (t) => {
|
||||
const { path, store } = await fixture(t);
|
||||
const botId = `${channel}_one`;
|
||||
const otherBotId = `${channel}_two`;
|
||||
await store.ensure(botId);
|
||||
await store.ensure(otherBotId);
|
||||
const controller = awareController(store, fakeController([botId, otherBotId]));
|
||||
const handler = createHandler(controller);
|
||||
assert.equal(endpoints.setContextEnhancement, SET_CONTEXT_ENHANCEMENT_ENDPOINT);
|
||||
const before = await handler(endpoints.status, {});
|
||||
assert.equal(before.ok, true);
|
||||
assert.deepEqual(before.value.bots[0].contextEnhancement, DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
const selected = enabled({ fields: ['botId', 'channel', 'botId'], guidance: ' \n ' });
|
||||
const saved = await handler(endpoints.setContextEnhancement, { botId, config: selected });
|
||||
assert.equal(saved.ok, true);
|
||||
assert.equal(saved.value.bots.length, 2);
|
||||
assert.deepEqual(saved.value.bots[0].contextEnhancement, enabled({ fields: ['channel', 'botId'], guidance: '' }));
|
||||
assert.deepEqual(saved.value.bots[1].contextEnhancement, DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
const original = store.contextEnhancementFor(botId);
|
||||
const diskBefore = await readFile(path, 'utf8');
|
||||
for (const config of [enabled({ groupEnabled: 'yes' }), enabled({ fields: ['secret'] }), enabled({ guidance: 'x'.repeat(8001) })]) {
|
||||
const failed = await handler(endpoints.setContextEnhancement, { botId, config });
|
||||
assert.equal(failed.ok, false);
|
||||
assert.equal(failed.error.code, 'bad-request');
|
||||
assert.equal(store.contextEnhancementFor(botId), original);
|
||||
}
|
||||
assert.equal(await readFile(path, 'utf8'), diskBefore);
|
||||
const missing = await handler(endpoints.setContextEnhancement, { botId: 'missing_bot', config: enabled() });
|
||||
assert.equal(missing.ok, false);
|
||||
assert.equal(missing.error.code, 'workspace-bot-not-found');
|
||||
const cancelled = await handler(endpoints.setContextEnhancement, { botId, config: enabled() }, AbortSignal.abort());
|
||||
assert.equal(cancelled.ok, false);
|
||||
assert.equal(cancelled.error.code, 'cancelled');
|
||||
assert.equal(store.contextEnhancementFor(botId), original);
|
||||
await mkdir(`${path}.tmp`);
|
||||
const failedWrite = await handler(endpoints.setContextEnhancement, { botId, config: enabled({ fields: [] }) });
|
||||
assert.equal(failedWrite.ok, false);
|
||||
assert.equal(store.contextEnhancementFor(botId), original);
|
||||
assert.equal(await readFile(path, 'utf8'), diskBefore);
|
||||
await rm(`${path}.tmp`, { recursive: true });
|
||||
const cleared = await handler(endpoints.setContextEnhancement, { botId, config: enabled({ fields: [], guidance: '' }) });
|
||||
assert.equal(cleared.ok, true);
|
||||
assert.deepEqual(cleared.value.bots[0].contextEnhancement.fields, []);
|
||||
assert.equal(cleared.value.bots[0].contextEnhancement.guidance, '');
|
||||
});
|
||||
}
|
||||
547
test/context-enhancement-ui.test.mjs
Normal file
547
test/context-enhancement-ui.test.mjs
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import * as React from 'react';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
CONTEXT_ENHANCEMENT_FIELDS,
|
||||
CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH,
|
||||
CONTEXT_GUIDANCE_EXAMPLE,
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
} from '../src/channels/shared/context-enhancement.mjs';
|
||||
import { ContextEnhancementEditor, contextEnhancementLabel } from '../plugin-src/client/context-enhancement.js';
|
||||
import { AgentPresetEditor } from '../plugin-src/client/agent-preset.js';
|
||||
import { WorkspaceEditor } from '../plugin-src/client/workspace-editor.js';
|
||||
import { en, setImTranslator } from '../plugin-src/client/i18n.js';
|
||||
|
||||
const { act, create } = TestRenderer;
|
||||
const channels = await Promise.all([
|
||||
['weixin', 'WeixinSettingsTab'], ['wecom', 'WecomSettingsTab'], ['feishu', 'FeishuSettingsTab'],
|
||||
['dingtalk', 'DingtalkSettingsTab'], ['qq', 'QqSettingsTab'], ['slack', 'SlackSettingsTab'],
|
||||
['telegram', 'TelegramSettingsTab'], ['discord', 'DiscordSettingsTab'], ['whatsapp', 'WhatsappSettingsTab'],
|
||||
].map(async ([name, component]) => {
|
||||
const api = await import(`../plugin-src/client/channels/${name}/api.js`);
|
||||
const ui = await import(`../plugin-src/client/channels/${name}/index.js`);
|
||||
return {
|
||||
name,
|
||||
Settings: ui[component],
|
||||
normalize: api.normalizeBotsSnapshot ?? api.normalizeSnapshot,
|
||||
endpoints: Object.entries(api).find(([key]) => key.endsWith('_ENDPOINTS'))[1],
|
||||
};
|
||||
}));
|
||||
|
||||
function textOf(node) {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
return node?.children?.map(textOf).join('') ?? '';
|
||||
}
|
||||
|
||||
function button(root, name) {
|
||||
const found = root.findAllByType('button').find((node) => textOf(node) === name);
|
||||
assert.ok(found, `missing button: ${name}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function fields(root) {
|
||||
return root.findAllByType('input').filter((node) => CONTEXT_ENHANCEMENT_FIELDS.includes(node.props.name));
|
||||
}
|
||||
|
||||
function switches(root) {
|
||||
return root.findAllByProps({ role: 'switch' });
|
||||
}
|
||||
|
||||
function badge(root) {
|
||||
return textOf(root.findByProps({ className: 'dim-contextStatus' }));
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
for (let index = 0; index < 12; index += 1) await Promise.resolve();
|
||||
}
|
||||
|
||||
async function open(root) {
|
||||
await act(async () => { root.findByProps({ className: 'dim-contextEntry' }).props.onClick(); });
|
||||
}
|
||||
|
||||
async function click(root, label) {
|
||||
await act(async () => { button(root, label).props.onClick(); await flush(); });
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function mockWindow(t) {
|
||||
const previous = globalThis.window;
|
||||
const intervals = new Map();
|
||||
let nextId = 0;
|
||||
globalThis.window = {
|
||||
setInterval(callback, delay) { const id = ++nextId; intervals.set(id, { callback, delay }); return id; },
|
||||
clearInterval(id) { intervals.delete(id); },
|
||||
setTimeout() { return ++nextId; }, clearTimeout() {},
|
||||
requestAnimationFrame(callback) { callback(); return ++nextId; }, cancelAnimationFrame() {},
|
||||
};
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete globalThis.window;
|
||||
else globalThis.window = previous;
|
||||
});
|
||||
return { poll: () => [...intervals.values()].find(({ delay }) => delay === 15_000)?.callback() };
|
||||
}
|
||||
|
||||
function snapshot(channel, configs = [undefined, undefined]) {
|
||||
return {
|
||||
revision: 1,
|
||||
bots: configs.map((config, index) => ({
|
||||
botId: `${channel}_${index}`, configured: true, connected: true, state: 'connected',
|
||||
workspace: `/workspace/${index}`, agentPreset: '', contextEnhancement: config,
|
||||
bot: {
|
||||
name: `Bot ${index}`, username: `bot${index}`, idMasked: '123•••',
|
||||
accountIdMasked: '123•••', appIdMasked: 'cli•••', clientIdMasked: 'ding•••',
|
||||
},
|
||||
health: { status: 'healthy', summary: 'Connected', lastCheckedAt: 1_700_000_000_000 },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function mount(t, component, props, options) {
|
||||
let renderer;
|
||||
await act(async () => { renderer = create(React.createElement(component, props), options); await flush(); });
|
||||
t.after(async () => { await act(async () => { renderer.unmount(); await flush(); }); });
|
||||
return renderer;
|
||||
}
|
||||
|
||||
test('context settings default to off with sender ID and empty guidance, and explain the guidance', async (t) => {
|
||||
assert.equal(contextEnhancementLabel(undefined), '未开启');
|
||||
for (const [groupEnabled, directEnabled, label] of [
|
||||
[false, false, '未开启'], [true, false, '仅群聊'],
|
||||
[false, true, '仅私聊'], [true, true, '群聊和私聊'],
|
||||
]) {
|
||||
const config = { ...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG, groupEnabled, directEnabled };
|
||||
assert.equal(contextEnhancementLabel(config), label);
|
||||
}
|
||||
const saved = [];
|
||||
const renderer = await mount(t, ContextEnhancementEditor, { onSave: (value) => saved.push(value) });
|
||||
const entry = renderer.root.findByProps({ className: 'dim-contextEntry' });
|
||||
assert.equal(entry.props.disabled, false);
|
||||
assert.equal(entry.props['aria-haspopup'], 'dialog');
|
||||
await open(renderer.root);
|
||||
const contextHelp = renderer.root.findByProps({ 'aria-label': '查看上下文增强说明' });
|
||||
const contextTooltip = renderer.root.findByProps({ id: contextHelp.props['aria-describedby'] });
|
||||
assert.equal(renderer.root.findByProps({ role: 'dialog' }).props['aria-describedby'], contextTooltip.props.id);
|
||||
assert.match(textOf(contextTooltip), /选择在哪些会话中启用.*不查询平台 API/);
|
||||
assert.equal(renderer.root.findAllByType('p').some((node) => textOf(node).startsWith('选择在哪些会话中启用')), false);
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [false, false]);
|
||||
assert.equal(renderer.root.findByType('textarea').props.rows, 4);
|
||||
assert.deepEqual(fields(renderer.root).map((node) => node.props.name), CONTEXT_ENHANCEMENT_FIELDS);
|
||||
assert.deepEqual(fields(renderer.root).filter((node) => node.props.checked).map((node) => node.props.name), ['senderId']);
|
||||
assert.deepEqual(renderer.root.findAllByProps({ className: 'dim-contextFieldKey' }).map(textOf), CONTEXT_ENHANCEMENT_FIELDS);
|
||||
const fieldsHelp = renderer.root.findByProps({ 'aria-label': '查看来源字段说明' });
|
||||
const fieldsTooltip = renderer.root.findByProps({ id: fieldsHelp.props['aria-describedby'] });
|
||||
assert.match(textOf(fieldsTooltip), /增强提示词中请使用字段名(如 senderId、conversationType).*不会额外查询或补全/);
|
||||
assert.equal(renderer.root.findAllByType('p').some((node) => textOf(node).startsWith('增强提示词中请使用字段名')), false);
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
assert.equal(renderer.root.findByType('textarea').props.placeholder, CONTEXT_GUIDANCE_EXAMPLE);
|
||||
const help = renderer.root.findByProps({ 'aria-label': '查看增强提示词使用说明' });
|
||||
const tooltip = renderer.root.findByProps({ id: help.props['aria-describedby'] });
|
||||
assert.equal(help.props.type, 'button');
|
||||
assert.equal(help.props['aria-describedby'], tooltip.props.id);
|
||||
assert.match(textOf(tooltip), /使用说明.*dsh_im_source.*生效规则.*清空并保存.*隐私提示.*会话历史.*使用示例.*conversationType/s);
|
||||
assert.equal(textOf(tooltip.findByProps({ className: 'dim-contextTooltipExample' })), CONTEXT_GUIDANCE_EXAMPLE);
|
||||
assert.equal(renderer.root.findByType('textarea').props['aria-describedby'], tooltip.props.id);
|
||||
assert.equal(renderer.root.findAllByType('p').some((node) => /只需填写正文|发送者标识可能包含/.test(textOf(node))), false);
|
||||
const senderNameHelp = renderer.root.findByProps({ 'aria-label': '查看发送者昵称字段说明' });
|
||||
const senderNameTooltip = renderer.root.findByProps({ id: senderNameHelp.props['aria-describedby'] });
|
||||
assert.equal(senderNameHelp.props.type, 'button');
|
||||
assert.equal(senderNameHelp.parent.props.className, 'dim-contextHelp dim-contextFieldHelp');
|
||||
assert.equal(senderNameHelp.parent.parent.props.className, 'dim-contextFieldText');
|
||||
assert.match(textOf(senderNameTooltip), /不是每个渠道.*dsh_im_source.*省略 senderName/s);
|
||||
assert.deepEqual(saved, []);
|
||||
});
|
||||
|
||||
test('switches, fields, and guidance are local drafts until Save; Cancel and close discard them', async (t) => {
|
||||
const saved = [];
|
||||
const renderer = await mount(t, ContextEnhancementEditor, { onSave: (value) => saved.push(value) });
|
||||
for (const dismiss of ['取消', 'close', 'escape', 'backdrop']) {
|
||||
await open(renderer.root);
|
||||
await act(async () => { switches(renderer.root)[0].props.onChange({ target: { checked: true } }); });
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [true, false]);
|
||||
await act(async () => { switches(renderer.root)[1].props.onChange({ target: { checked: true } }); });
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [true, true]);
|
||||
await act(async () => {
|
||||
switches(renderer.root)[0].props.onChange({ target: { checked: false } });
|
||||
fields(renderer.root)[0].props.onChange({ target: { checked: false } });
|
||||
});
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [false, true]);
|
||||
await click(renderer.root, '清空');
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
assert.deepEqual(saved, []);
|
||||
await act(async () => {
|
||||
if (dismiss === 'close') renderer.root.findByProps({ 'aria-label': '关闭弹窗' }).props.onClick();
|
||||
else if (dismiss === 'escape') renderer.root.findByProps({ role: 'dialog' }).props.onKeyDown({
|
||||
key: 'Escape', preventDefault() {}, stopPropagation() {},
|
||||
});
|
||||
else if (dismiss === 'backdrop') {
|
||||
const target = {};
|
||||
renderer.root.findByProps({ className: 'dim-contextBackdrop' }).props.onMouseDown({ target, currentTarget: target });
|
||||
} else button(renderer.root, dismiss).props.onClick();
|
||||
await flush();
|
||||
});
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||||
await open(renderer.root);
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [false, false]);
|
||||
assert.deepEqual(fields(renderer.root).filter((node) => node.props.checked).map((node) => node.props.name), ['senderId']);
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
await click(renderer.root, '取消');
|
||||
}
|
||||
assert.deepEqual(saved, []);
|
||||
});
|
||||
|
||||
test('Save submits one complete config, preserves explicit empty fields/guidance, and fills the example alone', async (t) => {
|
||||
let saved;
|
||||
const calls = [];
|
||||
function Fixture() {
|
||||
const [config, setConfig] = React.useState(undefined);
|
||||
return React.createElement(ContextEnhancementEditor, {
|
||||
config,
|
||||
onSave(value) { saved = value; calls.push(value); setConfig(value); },
|
||||
});
|
||||
}
|
||||
const renderer = await mount(t, Fixture);
|
||||
await open(renderer.root);
|
||||
await act(async () => { switches(renderer.root)[0].props.onChange({ target: { checked: true } }); });
|
||||
// Read each freshly rendered checkbox so every independent edit uses the current draft.
|
||||
for (const name of CONTEXT_ENHANCEMENT_FIELDS) {
|
||||
await act(async () => { renderer.root.findByProps({ name }).props.onChange({ target: { checked: false } }); });
|
||||
}
|
||||
await click(renderer.root, '清空');
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(saved, { groupEnabled: true, directEnabled: false, fields: [], guidance: '' });
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||||
assert.equal(badge(renderer.root), '仅群聊');
|
||||
await open(renderer.root);
|
||||
assert.ok(fields(renderer.root).every((node) => !node.props.checked));
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
assert.equal(renderer.root.findByType('textarea').props.placeholder, CONTEXT_GUIDANCE_EXAMPLE);
|
||||
await click(renderer.root, '填入示例');
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, CONTEXT_GUIDANCE_EXAMPLE);
|
||||
assert.ok(fields(renderer.root).every((node) => !node.props.checked));
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [true, false]);
|
||||
await click(renderer.root, '取消');
|
||||
const reloaded = await mount(t, ContextEnhancementEditor, { config: JSON.parse(JSON.stringify(saved)) });
|
||||
await open(reloaded.root);
|
||||
assert.equal(reloaded.root.findByType('textarea').props.value, '');
|
||||
assert.ok(fields(reloaded.root).every((node) => !node.props.checked));
|
||||
});
|
||||
|
||||
test('failed atomic saves retain the draft, lock edits and duplicate submits, and can be retried', async (t) => {
|
||||
const request = deferred();
|
||||
const calls = [];
|
||||
const renderer = await mount(t, ContextEnhancementEditor, {
|
||||
config: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
onSave(value) { calls.push(value); return calls.length === 1 ? request.promise : undefined; },
|
||||
});
|
||||
await open(renderer.root);
|
||||
await act(async () => {
|
||||
switches(renderer.root)[1].props.onChange({ target: { checked: true } });
|
||||
renderer.root.findByType('textarea').props.onChange({ target: { value: 'local draft' } });
|
||||
});
|
||||
const saveButton = button(renderer.root, '保存');
|
||||
await act(async () => { saveButton.props.onClick(); saveButton.props.onClick(); await flush(); });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(renderer.root.findByProps({ role: 'dialog' }).props['aria-busy'], true);
|
||||
assert.ok(renderer.root.findAllByType('input').every((node) => node.props.disabled));
|
||||
assert.ok(renderer.root.findAllByType('button').every((node) => node.props.disabled || node.props.className === 'dim-contextEntry'));
|
||||
await act(async () => {
|
||||
renderer.root.findByType('textarea').props.onChange({ target: { value: 'do not commit' } });
|
||||
renderer.root.findByProps({ role: 'dialog' }).props.onKeyDown({ key: 'Escape', preventDefault() {}, stopPropagation() {} });
|
||||
button(renderer.root, '取消').props.onClick();
|
||||
});
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 1);
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, 'local draft');
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
request.reject(new Error('Save rejected'));
|
||||
await act(async () => { await flush(); });
|
||||
assert.equal(textOf(renderer.root.findByProps({ role: 'alert' })), 'Save rejected');
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, 'local draft');
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [false, true]);
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(calls[1], calls[0]);
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||||
});
|
||||
|
||||
test('Weixin displays and saves only its supported direct scope', async (t) => {
|
||||
const saved = [];
|
||||
const renderer = await mount(t, ContextEnhancementEditor, {
|
||||
groupSupported: false,
|
||||
config: { ...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG, groupEnabled: true },
|
||||
onSave(value) { saved.push(value); },
|
||||
});
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
await open(renderer.root);
|
||||
const group = switches(renderer.root)[0];
|
||||
assert.equal(group.props.checked, false);
|
||||
assert.equal(group.props.disabled, true);
|
||||
const groupNotice = renderer.root.findByProps({ id: group.props['aria-describedby'] });
|
||||
assert.equal(textOf(groupNotice), '(当前渠道不支持群聊)');
|
||||
assert.equal(groupNotice.props.className, 'dim-contextUnavailable');
|
||||
assert.equal(groupNotice.parent.props.className, 'dim-contextSwitchLabel');
|
||||
await act(async () => {
|
||||
group.props.onChange({ target: { checked: true } });
|
||||
switches(renderer.root)[1].props.onChange({ target: { checked: true } });
|
||||
});
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(saved[0].groupEnabled, false);
|
||||
assert.equal(saved[0].directEnabled, true);
|
||||
});
|
||||
|
||||
test('dialog traps Tab and external focus, cancels with Escape, and restores entry focus', async (t) => {
|
||||
const previous = globalThis.document;
|
||||
const listeners = new Map();
|
||||
const document = { activeElement: null, addEventListener(type, fn) { listeners.set(type, fn); }, removeEventListener(type) { listeners.delete(type); } };
|
||||
globalThis.document = document;
|
||||
t.after(() => { if (previous === undefined) delete globalThis.document; else globalThis.document = previous; });
|
||||
const focusable = () => ({ focus() { document.activeElement = this; } });
|
||||
const entry = focusable();
|
||||
const first = focusable();
|
||||
const last = focusable();
|
||||
const dialog = { ...focusable(), contains(node) { return [this, first, last].includes(node); }, querySelectorAll() { return [first, last]; } };
|
||||
const renderer = await mount(t, ContextEnhancementEditor, {}, {
|
||||
createNodeMock(element) {
|
||||
if (element.props.className === 'dim-contextEntry') return entry;
|
||||
if (element.props.role === 'dialog') return dialog;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
await open(renderer.root);
|
||||
assert.equal(document.activeElement, dialog);
|
||||
const keydown = (shiftKey) => {
|
||||
let prevented = false;
|
||||
renderer.root.findByProps({ role: 'dialog' }).props.onKeyDown({ key: 'Tab', shiftKey, preventDefault() { prevented = true; } });
|
||||
assert.equal(prevented, true);
|
||||
};
|
||||
keydown(false);
|
||||
assert.equal(document.activeElement, first);
|
||||
keydown(true);
|
||||
assert.equal(document.activeElement, last);
|
||||
keydown(false);
|
||||
assert.equal(document.activeElement, first);
|
||||
listeners.get('focusin')({ target: {} });
|
||||
assert.equal(document.activeElement, dialog);
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ role: 'dialog' }).props.onKeyDown({ key: 'Escape', preventDefault() {}, stopPropagation() {} });
|
||||
await flush();
|
||||
});
|
||||
assert.equal(document.activeElement, entry);
|
||||
assert.equal(listeners.has('focusin'), false);
|
||||
});
|
||||
|
||||
test('all context dialog copy and validation errors localize without translating the saved body', async (t) => {
|
||||
setImTranslator((key) => en[key] ?? key);
|
||||
t.after(() => setImTranslator(null));
|
||||
const saved = [];
|
||||
const renderer = await mount(t, ContextEnhancementEditor, { groupSupported: false, onSave: (value) => saved.push(value) });
|
||||
assert.equal(badge(renderer.root), 'Not enabled');
|
||||
await open(renderer.root);
|
||||
assert.doesNotMatch(textOf(renderer.root), /[\p{Script=Han}]/u);
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
assert.equal(renderer.root.findByType('textarea').props.placeholder, en[CONTEXT_GUIDANCE_EXAMPLE]);
|
||||
assert.equal(renderer.root.findByType('textarea').props.maxLength, CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH);
|
||||
const localizedExample = textOf(renderer.root.findByProps({ className: 'dim-contextTooltipExample' }));
|
||||
assert.equal(localizedExample, en[CONTEXT_GUIDANCE_EXAMPLE]);
|
||||
await click(renderer.root, 'Use example');
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, localizedExample);
|
||||
await click(renderer.root, 'Clear');
|
||||
await act(async () => {
|
||||
renderer.root.findByType('textarea').props.onChange({ target: { value: 'x'.repeat(CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH + 1) } });
|
||||
});
|
||||
await click(renderer.root, 'Save');
|
||||
assert.match(textOf(renderer.root.findByProps({ role: 'alert' })), /Guidance must not exceed 8000 characters/);
|
||||
assert.deepEqual(saved, []);
|
||||
});
|
||||
|
||||
test('all nine APIs preserve canonical, empty, absent and damaged context configurations', () => {
|
||||
for (const channel of channels) {
|
||||
assert.equal(channel.endpoints.setContextEnhancement, 'bot.context-enhancement.set');
|
||||
const empty = { groupEnabled: true, directEnabled: false, fields: [], guidance: '' };
|
||||
const raw = snapshot(channel.name, [undefined, empty]);
|
||||
const normalized = channel.normalize(raw);
|
||||
assert.deepEqual(normalized.bots[0].contextEnhancement, DEFAULT_CONTEXT_ENHANCEMENT_CONFIG, channel.name);
|
||||
assert.deepEqual(normalized.bots[1].contextEnhancement, empty, channel.name);
|
||||
const canonical = channel.normalize(snapshot(channel.name, [{
|
||||
groupEnabled: false, directEnabled: true, fields: ['botId', 'channel', 'botId'], guidance: ' \n ',
|
||||
}]));
|
||||
assert.deepEqual(canonical.bots[0].contextEnhancement, {
|
||||
groupEnabled: false, directEnabled: true, fields: ['channel', 'botId'], guidance: '',
|
||||
}, channel.name);
|
||||
const damaged = channel.normalize(snapshot(channel.name, [{ groupEnabled: 'true', fields: ['secret'] }]));
|
||||
assert.equal(damaged.bots[0].contextEnhancement.groupEnabled, false, channel.name);
|
||||
assert.equal(damaged.bots[0].contextEnhancement.directEnabled, false, channel.name);
|
||||
assert.deepEqual(raw.bots[1].contextEnhancement, empty);
|
||||
}
|
||||
});
|
||||
|
||||
test('all nine cards save through their existing RPC path, isolate bots and preserve explicit empty on reload', async (t) => {
|
||||
mockWindow(t);
|
||||
for (const channel of channels) await t.test(channel.name, async (t) => {
|
||||
let current = snapshot(channel.name);
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === 'connection.status') return { ok: true, value: current };
|
||||
assert.equal(endpoint, 'bot.context-enhancement.set');
|
||||
current = { ...current, revision: 2, bots: current.bots.map((bot) => bot.botId === payload.botId
|
||||
? { ...bot, contextEnhancement: payload.config } : bot) };
|
||||
return { ok: true, value: current };
|
||||
};
|
||||
const renderer = await mount(t, channel.Settings, { rpcCall });
|
||||
const first = () => renderer.root.findByProps({ 'data-bot-id': `${channel.name}_0` });
|
||||
const second = () => renderer.root.findByProps({ 'data-bot-id': `${channel.name}_1` });
|
||||
const editors = first().findAll((node) => [WorkspaceEditor, AgentPresetEditor, ContextEnhancementEditor].includes(node.type));
|
||||
assert.deepEqual(editors.map((node) => node.type), [WorkspaceEditor, AgentPresetEditor, ContextEnhancementEditor]);
|
||||
assert.equal(badge(first()), '未开启');
|
||||
await open(first());
|
||||
await act(async () => { switches(first())[1].props.onChange({ target: { checked: true } }); });
|
||||
for (const name of CONTEXT_ENHANCEMENT_FIELDS) await act(async () => {
|
||||
first().findByProps({ name }).props.onChange({ target: { checked: false } });
|
||||
});
|
||||
await click(first(), '清空');
|
||||
assert.deepEqual(calls.map((call) => call.endpoint), ['connection.status']);
|
||||
assert.equal(badge(first()), '未开启');
|
||||
assert.equal(badge(second()), '未开启');
|
||||
await click(first(), '保存');
|
||||
const mutations = calls.filter((call) => call.endpoint !== 'connection.status');
|
||||
assert.deepEqual(mutations, [{ endpoint: 'bot.context-enhancement.set', payload: {
|
||||
botId: `${channel.name}_0`, config: { groupEnabled: false, directEnabled: true, fields: [], guidance: '' },
|
||||
} }]);
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
assert.equal(badge(second()), '未开启');
|
||||
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
|
||||
assert.equal(current.bots[0].workspace, '/workspace/0');
|
||||
const reloaded = await mount(t, channel.Settings, { rpcCall });
|
||||
await open(reloaded.root.findByProps({ 'data-bot-id': `${channel.name}_0` }));
|
||||
assert.equal(reloaded.root.findByProps({ role: 'dialog' }).findByType('textarea').props.value, '');
|
||||
assert.ok(fields(reloaded.root).every((node) => !node.props.checked));
|
||||
});
|
||||
});
|
||||
|
||||
test('all nine settings fence stale polls and reconcile against the actual saved response', async (t) => {
|
||||
const timers = mockWindow(t);
|
||||
for (const channel of channels) await t.test(channel.name, async (t) => {
|
||||
const oldRead = deferred();
|
||||
const original = snapshot(channel.name);
|
||||
let current = original;
|
||||
let reads = 0;
|
||||
const actual = { groupEnabled: false, directEnabled: true, fields: ['botId'], guidance: '' };
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === 'connection.status') {
|
||||
reads += 1;
|
||||
return reads === 2 ? oldRead.promise : { ok: true, value: current };
|
||||
}
|
||||
assert.equal(endpoint, 'bot.context-enhancement.set');
|
||||
current = snapshot(channel.name, [actual, undefined]);
|
||||
return { ok: true, value: current };
|
||||
};
|
||||
const renderer = await mount(t, channel.Settings, { rpcCall });
|
||||
await act(async () => { void timers.poll(); await flush(); });
|
||||
assert.equal(reads, 2);
|
||||
const first = () => renderer.root.findByProps({ 'data-bot-id': `${channel.name}_0` });
|
||||
await open(first());
|
||||
// The server response, not an optimistic copy of this draft, is authoritative.
|
||||
await click(first(), '保存');
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
oldRead.resolve({ ok: true, value: original });
|
||||
await act(async () => { await flush(); });
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
await open(first());
|
||||
assert.deepEqual(fields(first()).filter((node) => node.props.checked).map((node) => node.props.name), ['botId']);
|
||||
assert.equal(first().findByProps({ role: 'dialog' }).findByType('textarea').props.value, '');
|
||||
});
|
||||
});
|
||||
|
||||
test('all nine settings ignore an older concurrent bot mutation after context settings are saved', async (t) => {
|
||||
mockWindow(t);
|
||||
for (const channel of channels) await t.test(channel.name, async (t) => {
|
||||
const reconnect = deferred();
|
||||
const original = snapshot(channel.name);
|
||||
let current = original;
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
if (endpoint === 'connection.status') return { ok: true, value: current };
|
||||
if (endpoint === 'bot.reconnect') return reconnect.promise;
|
||||
assert.equal(endpoint, 'bot.context-enhancement.set');
|
||||
current = snapshot(channel.name, [payload.config, undefined]);
|
||||
return { ok: true, value: current };
|
||||
};
|
||||
const renderer = await mount(t, channel.Settings, { rpcCall });
|
||||
const first = () => renderer.root.findByProps({ 'data-bot-id': `${channel.name}_0` });
|
||||
const second = renderer.root.findByProps({ 'data-bot-id': `${channel.name}_1` });
|
||||
await act(async () => { button(second, '检查连接').props.onClick(); await flush(); });
|
||||
await open(first());
|
||||
await act(async () => { switches(first())[1].props.onChange({ target: { checked: true } }); });
|
||||
await click(first(), '保存');
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
reconnect.resolve({ ok: true, value: original });
|
||||
await act(async () => { await flush(); });
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
assert.equal(first().findByProps({ className: 'dim-contextEntry' }).props.disabled, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('all nine failed save RPCs keep runtime state and drafts intact through status reconciliation', async (t) => {
|
||||
mockWindow(t);
|
||||
for (const channel of channels) await t.test(channel.name, async (t) => {
|
||||
const original = snapshot(channel.name);
|
||||
const calls = [];
|
||||
const rpcCall = async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload });
|
||||
if (endpoint === 'connection.status') return { ok: true, value: original };
|
||||
assert.equal(endpoint, 'bot.context-enhancement.set');
|
||||
return { ok: false, error: { code: 'context-enhancement-invalid', message: 'Save rejected' } };
|
||||
};
|
||||
const renderer = await mount(t, channel.Settings, { rpcCall });
|
||||
const first = () => renderer.root.findByProps({ 'data-bot-id': `${channel.name}_0` });
|
||||
await open(first());
|
||||
await act(async () => { switches(first())[1].props.onChange({ target: { checked: true } }); });
|
||||
await click(first(), '清空');
|
||||
await click(first(), '保存');
|
||||
assert.equal(badge(first()), '未开启');
|
||||
assert.equal(switches(first())[1].props.checked, true);
|
||||
const dialog = first().findByProps({ role: 'dialog' });
|
||||
assert.equal(dialog.findByType('textarea').props.value, '');
|
||||
assert.ok(textOf(dialog.findByProps({ role: 'alert' })));
|
||||
assert.equal(original.bots[0].contextEnhancement, undefined);
|
||||
assert.equal(calls.filter((call) => call.endpoint !== 'connection.status').length, 1);
|
||||
await click(first(), '取消');
|
||||
await open(first());
|
||||
assert.equal(switches(first())[1].props.checked, false);
|
||||
assert.equal(first().findByProps({ role: 'dialog' }).findByType('textarea').props.value, '');
|
||||
});
|
||||
});
|
||||
|
||||
test('the approved neutral entry and theme-aware modal keep responsive labels and touch targets', async () => {
|
||||
const styles = await readFile(new URL('../plugin-src/client/styles.js', import.meta.url), 'utf8');
|
||||
assert.match(styles, /\.dim-contextEntry \{[^}]*min-height: 40px;[^}]*minmax\(0, 1fr\)[^}]*border-radius: 8px;[^}]*font-size: 13px;/);
|
||||
assert.match(styles, /\.dim-contextStatus\[data-active="true"\] \{[^}]*--dsw-alias-state-business-primary/);
|
||||
assert.match(styles, /\.dim-contextDialog \{[^}]*width: min\(450px, 100%\);[^}]*overflow-y: auto;[^}]*border-radius: 12px;[^}]*--dsw-alias-bg-layer-3/);
|
||||
assert.match(styles, /\.dim-contextFields \{[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/);
|
||||
assert.match(styles, /\.dim-contextSwitches \{[^}]*grid-template-columns: 1fr;[^}]*gap: 2px;/);
|
||||
assert.match(styles, /\.dim-contextGuidance textarea \{[^}]*min-height: 88px;/);
|
||||
assert.match(styles, /\.dim-contextGuidance textarea::placeholder \{[^}]*--dsw-alias-label-tertiary[^}]*opacity: 1;/);
|
||||
assert.match(styles, /\.dim-contextFieldKey \{[^}]*ui-monospace/);
|
||||
assert.match(styles, /\.dim-contextFieldText \{[^}]*grid-template-columns: max-content max-content;[^}]*column-gap: 5px;/);
|
||||
assert.match(styles, /\.dim-contextTooltip\.dim-contextFieldTooltip \{[^}]*right: 0;[^}]*left: auto;/);
|
||||
assert.match(styles, /@media \(pointer: coarse\) \{\s*\.dim-contextEntry[^}]*min-height: 44px;/);
|
||||
assert.match(styles, /\.dim-contextLabel \{[^}]*overflow-wrap: anywhere;/);
|
||||
assert.match(styles, /\.dim-contextTooltip \{[^}]*opacity: 0;[^}]*visibility: hidden;/);
|
||||
assert.match(styles, /\.dim-contextTooltip\.dim-contextGuidanceTooltip \{[^}]*bottom: calc\(100% \+ 7px\);[^}]*overflow-y: auto;/);
|
||||
assert.match(styles, /\.dim-contextHeader \{[^}]*position: relative;/);
|
||||
assert.match(styles, /\.dim-contextLegend \{[^}]*position: relative;[^}]*inline-flex/);
|
||||
assert.match(styles, /\.dim-contextHelp:hover \.dim-contextTooltip, \.dim-contextHelp:focus-within \.dim-contextTooltip \{[^}]*opacity: 1;[^}]*visibility: visible;/);
|
||||
const office = await readFile(new URL('../plugin-src/client/channels/office/index.js', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(office, /ContextEnhancement|context-enhancement/);
|
||||
});
|
||||
253
test/context-enhancement.test.mjs
Normal file
253
test/context-enhancement.test.mjs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
CONTEXT_ENHANCEMENT_FIELDS,
|
||||
CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH,
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
DEFAULT_CONTEXT_GUIDANCE,
|
||||
captureContextEnhancement,
|
||||
enhanceContextContent,
|
||||
normalizeContextEnhancementConfig,
|
||||
validateContextEnhancementConfig,
|
||||
} from '../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
function config(overrides = {}) {
|
||||
return {
|
||||
...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
groupEnabled: true,
|
||||
fields: CONTEXT_ENHANCEMENT_FIELDS,
|
||||
guidance: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(settings = config(), conversationType = 'group', botId = 'bot_one') {
|
||||
return captureContextEnhancement({ botId, getSettings: () => settings }, conversationType);
|
||||
}
|
||||
|
||||
function sourceOf(text) {
|
||||
const match = text.match(/<dsh_im_source>(.*?)<\/dsh_im_source>/s);
|
||||
return match ? JSON.parse(match[1]) : null;
|
||||
}
|
||||
|
||||
test('context config defaults are off with sender ID only and empty guidance', () => {
|
||||
assert.equal(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.groupEnabled, false);
|
||||
assert.equal(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.directEnabled, false);
|
||||
assert.equal(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.guidance, '');
|
||||
assert.deepEqual(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.fields, ['senderId']);
|
||||
assert.equal(Object.isFrozen(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.fields), true);
|
||||
for (const guidance of ['', ' \r\n\t ']) {
|
||||
assert.deepEqual(validateContextEnhancementConfig(config({ fields: [], guidance })), {
|
||||
groupEnabled: true, directEnabled: false, fields: [], guidance: '',
|
||||
});
|
||||
}
|
||||
const canonical = validateContextEnhancementConfig(config({
|
||||
fields: ['botId', 'senderName', 'botId', 'channel'], guidance: ' custom text\n',
|
||||
}));
|
||||
assert.deepEqual(canonical.fields, ['channel', 'senderName', 'botId']);
|
||||
assert.equal(canonical.guidance, ' custom text\n');
|
||||
assert.equal(Object.isFrozen(canonical), true);
|
||||
assert.equal(Object.isFrozen(canonical.fields), true);
|
||||
});
|
||||
|
||||
test('context saves reject missing, unknown and mistyped fields while normalization fails off', () => {
|
||||
const invalid = [
|
||||
undefined, null, [], {}, true,
|
||||
{ ...config(), unexpected: true },
|
||||
{ groupEnabled: true, directEnabled: false, fields: [] },
|
||||
config({ groupEnabled: 'true' }), config({ directEnabled: 1 }),
|
||||
config({ fields: null }), config({ fields: 'channel' }),
|
||||
config({ fields: ['channel', 'token'] }), config({ fields: [null] }),
|
||||
config({ fields: Array(1) }),
|
||||
config({ guidance: null }),
|
||||
config({ guidance: 'x'.repeat(CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH + 1) }),
|
||||
new Proxy({}, { getPrototypeOf() { throw new Error('bad config'); } }),
|
||||
{ ...config(), get guidance() { throw new Error('bad guidance'); } },
|
||||
];
|
||||
for (const value of invalid) {
|
||||
assert.throws(() => validateContextEnhancementConfig(value));
|
||||
assert.equal(normalizeContextEnhancementConfig(value), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
assert.equal(captureContextEnhancement({ botId: 'bot_one', getSettings: () => value }, 'group'), null);
|
||||
}
|
||||
assert.equal(validateContextEnhancementConfig(config({
|
||||
guidance: 'x'.repeat(CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH),
|
||||
})).guidance.length, CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH);
|
||||
});
|
||||
|
||||
test('off path preserves content identity and never reads fields, guidance, source or formatter', (t) => {
|
||||
const contents = [' \n\t原文\r\n ', '', [], [{ type: 'text', text: 'x' }], { untouched: true }];
|
||||
let sourceCalls = 0;
|
||||
let settingReads = 0;
|
||||
const throwIfRead = () => { throw new Error('off path must not inspect this'); };
|
||||
const settings = {
|
||||
groupEnabled: false, directEnabled: false,
|
||||
get fields() { return throwIfRead(); },
|
||||
get guidance() { return throwIfRead(); },
|
||||
};
|
||||
const provider = {
|
||||
get botId() { return throwIfRead(); },
|
||||
getSettings() { settingReads += 1; return settings; },
|
||||
};
|
||||
const stringify = t.mock.method(JSON, 'stringify', throwIfRead);
|
||||
try {
|
||||
for (const content of contents) {
|
||||
for (const kind of ['direct', 'group']) {
|
||||
const captured = captureContextEnhancement(provider, kind);
|
||||
assert.equal(captured, null);
|
||||
assert.equal(enhanceContextContent(content, captured, () => {
|
||||
sourceCalls += 1;
|
||||
throwIfRead();
|
||||
}), content);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stringify.mock.restore();
|
||||
}
|
||||
assert.equal(settingReads, contents.length * 2);
|
||||
assert.equal(sourceCalls, 0);
|
||||
assert.equal(stringify.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('only the actual conversation type can enable capture, regardless of selected fields', () => {
|
||||
for (const groupEnabled of [false, true]) {
|
||||
for (const directEnabled of [false, true]) {
|
||||
const settings = config({ groupEnabled, directEnabled, fields: [], guidance: 'guidance' });
|
||||
assert.equal(Boolean(snapshot(settings, 'group')), groupEnabled);
|
||||
assert.equal(Boolean(snapshot(settings, 'direct')), directEnabled);
|
||||
}
|
||||
}
|
||||
const provider = { getSettings() { throw new Error('read failed'); } };
|
||||
for (const kind of ['group', 'direct', 'single', 'unknown', null, undefined]) {
|
||||
assert.equal(captureContextEnhancement(provider, kind), null);
|
||||
}
|
||||
for (const kind of ['group', 'direct']) {
|
||||
const offSettings = {
|
||||
[kind === 'group' ? 'groupEnabled' : 'directEnabled']: false,
|
||||
get fields() { throw new Error('no field projection'); },
|
||||
get guidance() { throw new Error('no guidance lookup'); },
|
||||
};
|
||||
assert.equal(snapshot(offSettings, kind), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('all 32 source-field subsets are projected in canonical order with no hidden fields', () => {
|
||||
const expected = {
|
||||
channel: 'telegram', conversationType: 'group',
|
||||
senderId: '123', senderName: '张三', botId: 'bot_one',
|
||||
};
|
||||
for (let mask = 0; mask < 32; mask += 1) {
|
||||
const fields = CONTEXT_ENHANCEMENT_FIELDS.filter((_field, index) => mask & (1 << index));
|
||||
const selected = snapshot(config({ fields: [...fields].reverse() }));
|
||||
const actual = enhanceContextContent(' original\n', selected, () => ({
|
||||
channel: 'telegram', senderId: 123, senderName: '张三',
|
||||
botId: 'platform-secret-not-used', conversationType: 'direct', token: 'never-sent',
|
||||
}));
|
||||
if (fields.length === 0) {
|
||||
assert.equal(actual, ' original\n');
|
||||
} else {
|
||||
assert.deepEqual(sourceOf(actual), Object.fromEntries(fields.map((field) => [field, expected[field]])));
|
||||
assert.deepEqual(Object.keys(sourceOf(actual)), fields);
|
||||
assert.equal(actual.endsWith('\n\n original\n'), true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('source availability and guidance are independent, including empty multimodal identity', () => {
|
||||
const content = [{ type: 'image', image_url: { url: 'memory:image' } }];
|
||||
const throwSource = () => { throw new Error('no metadata lookup required'); };
|
||||
assert.equal(enhanceContextContent(content, snapshot(config({ fields: [] })), throwSource), content);
|
||||
for (const senderName of [undefined, null, '', ' \t ', 12]) {
|
||||
assert.equal(enhanceContextContent(content, snapshot(config({ fields: ['senderName'] })), () => ({ senderName })), content);
|
||||
}
|
||||
const onlyGuidance = enhanceContextContent(content, snapshot(config({ fields: [], guidance: 'custom' })), throwSource);
|
||||
assert.equal(onlyGuidance[0].text, '<dsh_im_source_guidance>\ncustom\n</dsh_im_source_guidance>');
|
||||
assert.equal(onlyGuidance[1], content[0]);
|
||||
const onlyBot = enhanceContextContent('text', snapshot(config({ fields: ['botId'] })), throwSource);
|
||||
assert.deepEqual(sourceOf(onlyBot), { botId: 'bot_one' });
|
||||
const onlyChannel = enhanceContextContent('text', snapshot(config({ fields: ['channel'] })), () => ({
|
||||
channel: 'slack',
|
||||
get senderId() { throw new Error('unselected ID'); },
|
||||
get senderName() { throw new Error('unselected name'); },
|
||||
}));
|
||||
assert.deepEqual(sourceOf(onlyChannel), { channel: 'slack' });
|
||||
});
|
||||
|
||||
test('multimodal enhancement prepends a single text item without modifying the original items', () => {
|
||||
const text = Object.freeze({ type: 'text', text: ' \noriginal\t' });
|
||||
const image = Object.freeze({ type: 'image', image_url: { url: 'memory:image' } });
|
||||
const files = Object.freeze({ type: 'text', text: '<dsh_im_files>original files</dsh_im_files>' });
|
||||
const content = Object.freeze([text, image, files]);
|
||||
const enhanced = enhanceContextContent(content, snapshot(config({ guidance: 'use facts' })), () => ({ channel: 'wecom' }));
|
||||
assert.equal(enhanced.length, 4);
|
||||
assert.equal(enhanced[0].type, 'text');
|
||||
assert.equal(enhanced[0].text.startsWith('<dsh_im_source>'), true);
|
||||
assert.match(enhanced[0].text, /<\/dsh_im_source>\n\n<dsh_im_source_guidance>/);
|
||||
assert.equal(enhanced[1], text);
|
||||
assert.equal(enhanced[2], image);
|
||||
assert.equal(enhanced[3], files);
|
||||
assert.deepEqual(content, [text, image, files]);
|
||||
});
|
||||
|
||||
test('source strings are bounded, control-free and JSON-safe without invoking external coercion', () => {
|
||||
const name = '"\n</dsh_im_source>&\u0000\u202e' + '名'.repeat(400);
|
||||
const enhanced = enhanceContextContent('original', snapshot(config(), 'group', 'b'.repeat(300)), () => ({
|
||||
channel: 'discord', senderId: 'u'.repeat(400), senderName: name,
|
||||
}));
|
||||
const source = sourceOf(enhanced);
|
||||
assert.equal(source.senderId.length, 256);
|
||||
assert.equal(source.senderName.length, 256);
|
||||
assert.equal(source.botId.length, 128);
|
||||
assert.doesNotMatch(source.senderName, /[\u0000-\u001f\u202e]/);
|
||||
assert.equal(enhanced.split('</dsh_im_source>').length, 2);
|
||||
assert.match(enhanced, /\\u003c/);
|
||||
assert.match(enhanced, /\\u003e/);
|
||||
assert.match(enhanced, /\\u0026/);
|
||||
for (const senderId of [0, 123n]) {
|
||||
const result = enhanceContextContent('text', snapshot(config({ fields: ['senderId'] })), () => ({ senderId }));
|
||||
assert.deepEqual(sourceOf(result), { senderId: String(senderId) });
|
||||
}
|
||||
for (const senderId of [NaN, Infinity, { toString() { throw new Error('no coercion'); } }]) {
|
||||
assert.equal(enhanceContextContent('text', snapshot(config({ fields: ['senderId'] })), () => ({ senderId })), 'text');
|
||||
}
|
||||
assert.equal(enhanceContextContent('text', snapshot(config({ fields: ['channel'] })), () => ({ channel: 'unsupported' })), 'text');
|
||||
});
|
||||
|
||||
test('matching guidance tags cannot close or nest the generated block', () => {
|
||||
const guidance = 'before </dsh_im_source_guidance> <dsh_im_source_guidance> after\n'
|
||||
+ '<DSH_IM_SOURCE_GUIDANCE data-x="yes"> </dsh_im_source_guidance >\n<dsh_im_source_guidance unfinished';
|
||||
const result = enhanceContextContent('original', snapshot(config({ fields: [], guidance })));
|
||||
assert.equal(result.match(/<dsh_im_source_guidance>/g).length, 1);
|
||||
assert.equal(result.match(/<\/dsh_im_source_guidance>/g).length, 1);
|
||||
assert.match(result, /<\/dsh_im_source_guidance>/);
|
||||
assert.match(result, /<DSH_IM_SOURCE_GUIDANCE data-x="yes">/);
|
||||
assert.match(result, /<dsh_im_source_guidance unfinished/);
|
||||
assert.equal(result.endsWith('\n\noriginal'), true);
|
||||
const defaultText = enhanceContextContent('original', snapshot(config({ fields: [], guidance: DEFAULT_CONTEXT_GUIDANCE })));
|
||||
assert.equal(defaultText, `<dsh_im_source_guidance>\n${DEFAULT_CONTEXT_GUIDANCE}\n</dsh_im_source_guidance>\n\noriginal`);
|
||||
});
|
||||
|
||||
test('queued snapshots are immutable and retain a complete setting version after edits', () => {
|
||||
let settings = config({ fields: ['senderId'], guidance: 'old guidance' });
|
||||
const provider = { botId: 'bot_queued', getSettings: () => settings };
|
||||
const before = captureContextEnhancement(provider, 'group');
|
||||
settings.fields.push('senderName');
|
||||
settings.guidance = 'new guidance';
|
||||
settings.groupEnabled = false;
|
||||
assert.equal(captureContextEnhancement(provider, 'group'), null);
|
||||
assert.deepEqual(before.config.fields, ['senderId']);
|
||||
const result = enhanceContextContent('queued', before, () => ({ senderId: 'old', senderName: 'new' }));
|
||||
assert.deepEqual(sourceOf(result), { senderId: 'old' });
|
||||
assert.match(result, /old guidance/);
|
||||
assert.doesNotMatch(result, /new guidance/);
|
||||
assert.equal(Object.isFrozen(before), true);
|
||||
assert.equal(Object.isFrozen(before.config.fields), true);
|
||||
settings = config({ fields: ['botId'], guidance: 'second version' });
|
||||
assert.notEqual(captureContextEnhancement(provider, 'group').config, before.config);
|
||||
});
|
||||
|
||||
test('enhancement-only source failures leave the original message processable', () => {
|
||||
const content = [{ type: 'text', text: 'still send me' }];
|
||||
assert.equal(enhanceContextContent(content, snapshot(), () => { throw new Error('broken source'); }), content);
|
||||
assert.equal(enhanceContextContent(content, snapshot(), () => ({ get channel() { throw new Error('broken getter'); } })), content);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue