mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 22:00:50 +08:00
feat: split group and direct context enhancement
This commit is contained in:
parent
38096d1589
commit
75ebaebf2e
21 changed files with 1423 additions and 873 deletions
|
|
@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed / 变更
|
||||
|
||||
- 上下文增强现在为群聊和私聊分别保存启用开关、来源字段与增强提示词,接收消息时只使用当前会话类型对应的配置。旧版共用字段与提示词会在升级后自动映射到两个场景,并在下一次成功写入机器人设置时无损保存为新结构,无需手工迁移。
|
||||
Context enhancement now stores independent enable switches, source fields, and guidance for group and direct chats, and inbound messages use only the matching conversation configuration. Existing shared fields and guidance are automatically mapped to both scopes after upgrade and are losslessly persisted in the new structure on the next successful bot-settings write, with no manual migration required.
|
||||
|
||||
## [4.2.1] - 2026-08-31
|
||||
|
||||
### Fixed / 修复
|
||||
|
|
|
|||
|
|
@ -140,9 +140,11 @@ See the [Proactive Delivery Guide](PROACTIVE_DELIVERY.en.md) ([简体中文](PRO
|
|||
|
||||
### 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.
|
||||
Open **Context enhancement** on a bot card to configure separate enable switches, source fields, and guidance for group and direct chats, then **Save** to apply the complete configuration atomically. The two scopes do not share settings. Each offers `channel`, `conversationType`, `senderId`, `senderName`, and `botId`, with only `senderId` selected by default. Only values selected for the current scope and 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 enabled, ordinary user messages receive the current scope's `<dsh_im_source>` prefix. Nonempty guidance for that scope is automatically wrapped in `<dsh_im_source_guidance>` tags. Both guidance fields start empty and have their own instructions, example, **Use example**, and **Clear** actions. No fields selected in the current scope means no source block. Commands, approvals and question answers keep their existing control paths.
|
||||
|
||||
Existing shared fields and guidance are automatically copied into both group and direct configurations during upgrade, while their original enable switches remain independent. The first read does not rewrite the settings file; the new structure is persisted through the existing mechanism on the next successful bot-settings write, with no manual migration required.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -143,9 +143,11 @@ dsh web
|
|||
|
||||
### 上下文增强
|
||||
|
||||
点击机器人卡片中的「上下文增强」,设置群聊/私聊范围、来源字段和增强提示词,点击「保存」后生效。五个可选字段为 `channel`、`conversationType`、`senderId`、`senderName`、`botId`,默认只选择 `senderId`;只发送勾选且当前消息已有的值,不查询平台 API 补全。微信当前只支持私聊。
|
||||
点击机器人卡片中的「上下文增强」,分别设置群聊和私聊的启用开关、来源字段与增强提示词,点击「保存」后原子生效。两个场景互不共用配置;五个可选字段均为 `channel`、`conversationType`、`senderId`、`senderName`、`botId`,各自默认只选择 `senderId`。插件只发送当前场景勾选且当前消息已有的值,不查询平台 API 补全。微信当前只支持私聊。
|
||||
|
||||
开启后,插件在普通用户消息前附加 `<dsh_im_source>` 来源块;非空的增强提示词自动包裹为 `<dsh_im_source_guidance>`。增强提示词默认留空,标题旁的问号提供使用说明和示例,也可以「填入示例」或「清空」正文;字段全部取消时不生成来源块。命令、审批和问题回答继续走原有控制链路。
|
||||
开启后,插件在普通用户消息前附加当前场景的 `<dsh_im_source>` 来源块;当前场景非空的增强提示词自动包裹为 `<dsh_im_source_guidance>`。两个场景的提示词默认留空,并分别提供说明、示例、「填入示例」和「清空」;当前场景字段全部取消时不生成来源块。命令、审批和问题回答继续走原有控制链路。
|
||||
|
||||
升级前已经保存的共用字段与增强提示词会自动复制到群聊、私聊两份配置,原有两个开关也分别保留。升级后的首次读取不会改写配置文件;之后任意一次机器人设置成功保存时,会随现有设置写入机制自动落盘为新结构,无需手工迁移。
|
||||
|
||||
当前会话类型未开启时,原有文字、图片、文件和会话处理保持不变,不组装增强内容,也不新增网络查询。草稿、清空后取消等操作不改变运行配置;保存不重连机器人、不重建会话,已经接收的消息仍使用接收时的配置。
|
||||
|
||||
|
|
|
|||
|
|
@ -194,13 +194,13 @@ HR 机器人
|
|||
→ 告诉模型希望怎样理解和使用这些事实
|
||||
```
|
||||
|
||||
当前功能方案把“上下文增强”设计为机器人卡片上的设置入口。点击后打开弹窗,从上到下设置三个维度:
|
||||
当前功能方案把“上下文增强”设计为机器人卡片上的设置入口。点击后打开弹窗,群聊与私聊各自设置三个维度:
|
||||
|
||||
- **在哪些会话中使用**:群聊和私聊两个独立开关,默认均关闭。
|
||||
- **提供哪些来源事实**:五个字段中默认只选 `senderId`,也可按当前入口增加或取消字段。
|
||||
- **如何使用这些事实**:增强提示词默认留空,可从问号查看使用说明和示例,也支持一键填入示例或清空。
|
||||
- **是否在当前场景使用**:群聊和私聊两个独立开关,默认均关闭。
|
||||
- **为当前场景提供哪些来源事实**:每个场景都可从五个字段中选择,默认只选 `senderId`。
|
||||
- **当前场景如何使用这些事实**:群聊与私聊各有一份增强提示词,默认留空,可分别查看说明、填入示例或清空。
|
||||
|
||||
群聊与私聊共用字段选择和提示词正文,分别决定是否启用。例如提示词可以是:
|
||||
群聊与私聊不共用字段选择或提示词正文。例如群聊提示词可以是:
|
||||
|
||||
```text
|
||||
有 senderId 时用它区分发言人;
|
||||
|
|
@ -208,7 +208,7 @@ HR 机器人
|
|||
不要在普通回复中展示 senderId 和 botId。
|
||||
```
|
||||
|
||||
用户只编辑增强提示词正文;非空时,`<dsh_im_source_guidance>` 和 `</dsh_im_source_guidance>` 由 dsh-im 自动添加。清空并保存后不再附加提示词块,也不会自动填入示例;所选来源字段仍按会话开关发送。
|
||||
私聊可以另行配置更轻松或更详细的表达策略。用户只编辑各场景的增强提示词正文;非空时,`<dsh_im_source_guidance>` 和 `</dsh_im_source_guidance>` 由 dsh-im 自动添加。清空并保存某一场景后,该场景不再附加提示词块,也不会自动填入示例;另一场景不受影响。
|
||||
|
||||
以当前会话已开启、五个字段全选且均可用、正文非空为例,dsh-im 最终发送:
|
||||
|
||||
|
|
@ -231,11 +231,11 @@ HR 机器人
|
|||
这项扩展已经超出原 Issue“传递五个字段”的最小范围。若实施,应该明确区分:
|
||||
|
||||
- 来源字段语义和真实值由 dsh-im 维护;用户可以选择发送哪些字段,不能手工改写来源事实;
|
||||
- 增强提示词按机器人保存正文,非空时由插件添加外围标签;首次未配置时保持为空,可选择填入示例;
|
||||
- 群聊、私聊独立控制启用范围;当前会话类型关闭时,两个增强块都不附加;
|
||||
- 增强提示词按机器人、按群聊/私聊场景分别保存正文,非空时由插件添加外围标签;首次未配置时保持为空,可选择填入对应示例;
|
||||
- 群聊、私聊分别保存启用开关和来源字段;当前会话类型关闭时,两个增强块都不附加;
|
||||
- 所选字段无可用值时省略来源块,提示词为空时省略提示词块;两者都为空则保持原消息;
|
||||
- 使用说明属于用户提示词增强,不是系统提示词,也不是完整 Agent Preset;
|
||||
- 本方案共用一份提示词;若未来需要分场景的不同模板,可以另行扩展,不等同于当前的两个启用开关。
|
||||
- 接收消息时只读取其真实会话类型对应的字段和提示词,不允许另一场景的配置串入。
|
||||
|
||||
这让“上下文增强”从一次性的数据附加,进一步成为用户可以明确配置的上下文策略:哪些场景需要增强、提供哪些信息,以及希望模型怎样使用,三者各有独立的选择空间。
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# 上下文增强验收记录
|
||||
|
||||
> 本文记录的是 Issue #58 初版(群聊与私聊共用字段和提示词)的历史验收结果。Issue #105 后续将两类会话拆成独立的完整配置,其验收以新版实现与测试为准;本文原始数据不回写改造。
|
||||
|
||||
记录日期:2026-08-29。
|
||||
|
||||
当前结论:自动检查、下列界面检查及实际设置保存/回读已通过。十一项真实场景中,8/11 具有主任务独立、完整的三轮证据:Telegram、飞书、微信、企业微信、WhatsApp、Slack、Discord 私聊,以及飞书 DeepSeek大会群聊;原设置均已确认恢复。QQ、钉钉私聊、钉钉指定群聊由用户明确确认已自行测试,并要求本任务不再重复操作。当前没有剩余客户端操作;独立证据与用户确认仍分别记录,不合并称为 11/11 独立通过。本记录不包含账号原始 ID、Session ID、昵称、手机号或私聊正文。
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# 机器人消息来源元数据注入方案
|
||||
|
||||
> 状态:实施中,九渠道与设置界面已接入,正在完成回归与真实客户端验收
|
||||
> 状态:Issue #105 独立群聊/私聊配置已完成,自动化回归与飞书真实客户端验收通过
|
||||
>
|
||||
> 日期:2026-08-26
|
||||
>
|
||||
> 更新日期:2026-08-29
|
||||
> 更新日期:2026-09-01
|
||||
>
|
||||
> 关联:[Issue #58](https://github.com/xmanrui/dsh-im/issues/58)、[风险说明](https://github.com/xmanrui/dsh-im/issues/58#issuecomment-5414350619)
|
||||
> 关联:[Issue #58](https://github.com/xmanrui/dsh-im/issues/58)、[Issue #105](https://github.com/xmanrui/dsh-im/issues/105)、[风险说明](https://github.com/xmanrui/dsh-im/issues/58#issuecomment-5414350619)
|
||||
|
||||
## 1. 结论
|
||||
|
||||
|
|
@ -14,17 +14,17 @@
|
|||
|
||||
> **上下文增强**
|
||||
|
||||
点击按钮打开设置弹窗,按从上到下的顺序提供:
|
||||
点击按钮打开设置弹窗,顶部依次展示“私聊”和“群聊”两个 Tab,默认打开私聊,一次只显示当前 Tab 的编辑内容。每个场景都独立提供:
|
||||
|
||||
1. **启用范围**:群聊、私聊两个独立开关,默认均关闭,不再设置一个总开关。
|
||||
1. **启用开关**:默认关闭,不再设置总开关。
|
||||
2. **来源字段**:从五个元数据字段中选择需要发送的字段,默认只勾选“发送者标识”(`senderId`)。
|
||||
3. **增强提示词**:正文默认留空,支持查看说明、填入示例和一键清空;非空时由插件自动包裹 `<dsh_im_source_guidance>` 标签。
|
||||
3. **增强提示词**:正文默认留空,支持查看说明、填入当前场景示例和一键清空;非空时由插件自动包裹 `<dsh_im_source_guidance>` 标签。
|
||||
|
||||
字段选择和增强提示词按机器人保存,群聊与私聊共用;两个开关只分别决定对应会话类型是否应用这套配置。
|
||||
配置按机器人保存,群聊与私聊的开关、字段和提示词互相独立。消息进入时只选择真实会话类型对应的一套配置。
|
||||
|
||||
方案遵守以下边界:
|
||||
|
||||
1. 配置按机器人独立保存;群聊和私聊开关默认均关闭,已有机器人缺少配置时也按关闭处理。
|
||||
1. 配置按机器人、按群聊/私聊场景独立保存;两个开关默认均关闭,已有机器人缺少配置时也按关闭处理。
|
||||
2. 只使用平台入站事件和机器人运行时已经携带的数据,不查询联系人、用户资料或其他平台 API。
|
||||
3. 不修改 Harness 代码、Harness RPC 或 `session.prompt` 协议。
|
||||
4. 不修改系统提示词。当前会话类型开启后,由 dsh-im 插件按所选字段和提示词正文生成增强块,存在内容的块才自动加上成对标签并附加到普通用户消息前。
|
||||
|
|
@ -84,8 +84,8 @@ dsh-im 当前已经按机器人和聊天维护 Harness Session,不同机器人
|
|||
### 3.1 目标
|
||||
|
||||
- 用户可以从每个机器人卡片打开设置,分别决定群聊、私聊是否启用上下文增强。
|
||||
- 用户可以选择发送五个来源字段中的哪些字段,默认只选 `senderId`;仅发送选中且当前事件中可用的值。
|
||||
- 通过按机器人自定义的增强提示词说明如何使用来源信息;正文默认留空,支持查看及填入示例和一键清空,非空正文由插件统一封装。
|
||||
- 用户可以为群聊和私聊分别选择五个来源字段中的哪些字段,均默认只选 `senderId`;仅发送当前场景选中且当前事件中可用的值。
|
||||
- 用户可以为群聊和私聊分别编写如何使用来源信息的增强提示词;正文默认留空,支持查看、填入对应示例和一键清空,非空正文由插件统一封装。
|
||||
- 群聊开启且选择发送者字段时,每条普通用户消息使用当次实际发送者的信息。
|
||||
- 九个渠道使用同一字段语义、同一设置交互和同一提示词封装格式。
|
||||
- 不增加每条消息的额外平台网络请求。
|
||||
|
|
@ -122,35 +122,31 @@ Agent Preset
|
|||
|
||||
### 4.2 弹窗结构
|
||||
|
||||
弹窗标题为“上下文增强”,包含以下三个设置区域及保存操作:
|
||||
弹窗标题为“上下文增强”,包含私聊、群聊两个 Tab、一个复用编辑区及一次保存操作:
|
||||
|
||||
```text
|
||||
上下文增强 [?] [关闭弹窗]
|
||||
|
||||
启用范围
|
||||
群聊中启用(当前渠道不支持群聊) [关闭]
|
||||
私聊中启用 [关闭]
|
||||
[ 私聊 ] [ 群聊 ]
|
||||
|
||||
启用 [关闭]
|
||||
来源字段 [?]
|
||||
[ ] 渠道 [ ] 会话类型
|
||||
channel conversationType
|
||||
[✓] 发送者标识 [ ] 发送者昵称 [?]
|
||||
senderId senderName
|
||||
[ ] 机器人标识
|
||||
botId
|
||||
|
||||
增强提示词 [?] [填入示例] [清空]
|
||||
[多行正文编辑区域,不需要填写外围标签]
|
||||
插件自动添加 <dsh_im_source_guidance> 成对标签。
|
||||
[当前 Tab 的正文编辑区域,不需要填写外围标签]
|
||||
|
||||
[取消] [保存]
|
||||
```
|
||||
|
||||
切换 Tab 只切换正在编辑的场景,不保存、不丢弃草稿;两个场景仍由底部“保存”一次原子提交。
|
||||
|
||||
标题右侧问号说明:
|
||||
|
||||
> 选择在哪些会话中启用、提供哪些来源字段,以及如何使用这些信息。仅使用已有消息元数据,不查询平台 API。
|
||||
|
||||
“来源字段”右侧问号说明:
|
||||
每个场景的“来源字段”右侧问号分别说明:
|
||||
|
||||
> 增强提示词中请使用字段名(如 senderId、conversationType)引用这些信息。只发送勾选且当前消息中可用的字段,不会额外查询或补全。
|
||||
|
||||
|
|
@ -158,7 +154,7 @@ Agent Preset
|
|||
|
||||
> 该字段不是每个渠道都能提供。当前消息没有发送者昵称时,即使已选择该字段,`<dsh_im_source>` 中也会省略 `senderName`。
|
||||
|
||||
“增强提示词”右侧问号说明中包含:
|
||||
每个场景的“增强提示词”右侧问号说明中包含:
|
||||
|
||||
> 只需填写正文,标签由插件自动添加。清空并保存后,不再附加增强提示词,已选来源字段仍按开关设置发送。
|
||||
|
||||
|
|
@ -166,44 +162,51 @@ Agent Preset
|
|||
|
||||
> 发送者标识可能包含平台用户 ID 或电话号码形式的标识。关闭开关不会删除已经写入会话历史的信息。
|
||||
|
||||
### 4.3 群聊与私聊开关
|
||||
### 4.3 群聊与私聊配置
|
||||
|
||||
- 两个开关相互独立,分别控制真实 `group` 与 `direct` 会话;不增加总开关。
|
||||
- 新机器人和缺少配置的已有机器人,两个开关都默认关闭。
|
||||
- 不论是否把 `conversationType` 发给模型,插件都使用入站事件的真实会话类型选择对应开关。
|
||||
- 群聊与私聊各自拥有完整的 `enabled`、`fields`、`guidance` 配置;不增加总开关,也不共享字段或提示词。
|
||||
- 新机器人和缺少配置的已有机器人,两个场景均默认关闭、只选 `senderId`、提示词留空。
|
||||
- 不论是否把 `conversationType` 发给模型,插件都使用入站事件的真实会话类型选择整套对应配置。
|
||||
- 只开启群聊时,私聊消息保持原样;只开启私聊时,群聊消息保持原样。
|
||||
- 两个开关都关闭时,仍可以预先编辑字段选择和提示词,但不向任何消息附加增强内容。
|
||||
- 仅支持私聊的渠道仍展示并禁用群聊开关,在“群聊中启用”右侧同行显示“(当前渠道不支持群聊)”,不承诺平台不存在的能力。
|
||||
- 两个开关都关闭时,仍可以分别预先编辑字段选择和提示词,但不向任何消息附加增强内容。
|
||||
- 仅支持私聊的渠道默认打开私聊 Tab;群聊 Tab 仍可查看,但其编辑区整体禁用并说明“当前渠道不支持群聊”。群聊字段和提示词保留、不被清空。
|
||||
|
||||
### 4.4 来源字段选择
|
||||
|
||||
- 五个字段全部以复选框展示,新配置默认只勾选“发送者标识”(`senderId`);字段名称与 JSON 键的映射见 5.1 节。
|
||||
- 每个场景都展示相同的五个字段复选框,新配置各自默认只勾选“发送者标识”(`senderId`);字段名称与 JSON 键的映射见 5.1 节。
|
||||
- 任意字段都可以取消,不设置强制勾选字段,也不暗中补回未选中的字段。
|
||||
- 群聊与私聊共用这套字段选择;启用范围和发送字段是两个独立配置维度。
|
||||
- 群聊与私聊的字段选择互相独立;只投影当前消息所属场景勾选的字段。
|
||||
- 用户只能决定是否发送字段,不能手工修改平台来源值或内部机器人标识。
|
||||
- 允许全部取消;此时不生成 `<dsh_im_source>` 块,非空增强提示词仍可按会话开关发送。
|
||||
- 任一场景允许全部取消;该场景不生成 `<dsh_im_source>` 块,非空增强提示词仍可按该场景开关发送。
|
||||
- 选中某字段不保证一定有值。例如仅选择 `senderName`,而本条消息没有昵称时,同样不生成来源块。
|
||||
- 字段选择变化不会自动重写用户的增强提示词;模板应按字段是否实际存在使用数据,不应猜测未提供的值。
|
||||
|
||||
### 4.5 增强提示词、帮助与一键清空
|
||||
|
||||
- 编辑项名称为“增强提示词”,群聊与私聊共用一份正文。
|
||||
- 新配置的正文默认为空;用户可以自行编辑、点击“填入示例”,或点击“清空”。
|
||||
- 两个 Tab 内的编辑项都简洁命名为“增强提示词”,各自保存正文;当前场景由选中的“群聊”或“私聊”Tab 明确表达。
|
||||
- 两份新配置的正文均默认为空;用户可以分别编辑、点击对应“填入示例”,或点击对应“清空”。
|
||||
- 正文为空时,编辑框通过 `placeholder` 灰色展示与“填入示例”相同的示例正文;占位内容不是输入值,不参与保存,也不会生成 guidance 块。
|
||||
- 标题右侧显示问号帮助入口,鼠标悬停或键盘聚焦时展示使用说明和简短示例。
|
||||
- 问号帮助中的“使用示例”与“填入示例”按钮共用同一份正文,不能分别维护两套示例。
|
||||
- 每个场景问号帮助中的“使用示例”与本场景“填入示例”按钮共用同一常量;群聊和私聊各维护一份明确的示例。
|
||||
- 用户只编辑正文;`<dsh_im_source_guidance>` 和 `</dsh_im_source_guidance>` 由插件在装配消息时自动添加,内置和自定义正文使用同一封装格式。
|
||||
- “清空”只把当前编辑区置为空,不改变开关和字段选择,也不立即影响正在运行的配置。
|
||||
- “清空”只把当前场景编辑区置为空,不改变任一开关和字段选择,不修改另一场景,也不立即影响正在运行的配置。
|
||||
- 清空并保存后持久化显式空字符串;刷新、重启或重新开启时仍为空。
|
||||
- 正文为空或仅含空白字符时,不附加 guidance 块,也不发送一对空标签;来源块按所选字段独立生成。
|
||||
- “填入示例”只把当前编辑区填入内置示例,点击“保存”后才生效。
|
||||
- “填入示例”只把当前场景编辑区填入对应内置示例,点击“保存”后才生效。
|
||||
- 内置示例必须以字段存在为前提提供说明,适应只选部分字段或当前事件缺少字段的情况。
|
||||
|
||||
可一键填入的内置示例正文:
|
||||
群聊可一键填入的内置示例正文:
|
||||
|
||||
```text
|
||||
仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
|
||||
conversationType是群聊时回复严肃一点,conversationType是私聊时回复一定要幽默搞笑,像周星驰的电影一样搞笑
|
||||
当前消息来自群聊,请使用严肃、克制、简洁的表达方式。
|
||||
```
|
||||
|
||||
私聊可一键填入的内置示例正文:
|
||||
|
||||
```text
|
||||
仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
|
||||
当前消息来自私聊,可以使用更轻松、幽默、详细的表达方式。
|
||||
```
|
||||
|
||||
### 4.6 保存与生效
|
||||
|
|
@ -215,7 +218,7 @@ conversationType是群聊时回复严肃一点,conversationType是私聊时回
|
|||
- 点击“取消”或关闭弹窗不保存草稿;重新打开时仍显示已保存值。
|
||||
- 保存成功后接收的下一条普通用户消息采用新配置;保存前已经接收或入队的消息不追溯修改。
|
||||
- 保存不触发机器人重连,不清除 Session,不中断当前回复;刷新页面和重启插件后配置保持。
|
||||
- 关闭某类会话或全部关闭不会清除字段选择与提示词正文;删除机器人时一并删除全部上下文增强配置。
|
||||
- 关闭某类会话或全部关闭不会清除任一场景的字段选择与提示词正文;删除机器人时一并删除全部上下文增强配置。
|
||||
- 修改 Workspace、Agent Preset、群响应模式或访问规则不改变本配置。
|
||||
|
||||
### 4.7 已确认的视觉样式
|
||||
|
|
@ -234,9 +237,10 @@ conversationType是群聊时回复严肃一点,conversationType是私聊时回
|
|||
|
||||
- 使用不透明主题背景、细边框与圆角,背景卡片加遮罩;标题在左上角,标题右侧问号通过悬停或键盘聚焦展示功能说明,关闭图标在右上角。
|
||||
- 参考最大宽度 450px、圆角 12px、内边距约 18px;窄屏自适应收缩,保持内容完整可见。
|
||||
- “启用范围”将群聊、私聊两个独立开关上下排列,每行左侧为名称、右侧为对应开关,避免误解为二选一。
|
||||
- “来源字段”标题右侧问号展示字段引用、选择和数据补全规则;下方以两列复选框排列,每项同时展示中文名称和可在增强提示词中直接使用的字段名。“发送者昵称”另带可悬停和聚焦的问号,说明字段缺失时的省略行为。五个字段的语义仍以 5.1 节为准。
|
||||
- “增强提示词”标题左对齐,右邻问号帮助入口;使用说明、生效规则、隐私提示和与“填入示例”共用的示例正文均收纳在帮助框中,并向上展开。“填入示例”和“清空”为右侧轻量文字操作,下方只保留多行编辑区。
|
||||
- “私聊”和“群聊”按此顺序使用同一行的两个等宽 Tab,默认选中私聊;一次只展示当前场景的“启用”、来源字段和“增强提示词”,减少纵向空间。选中态沿用现有业务强调色与主题令牌。
|
||||
- Tab 支持点击及左右方向键、Home、End 切换,并通过 `tablist`、`tab`、`tabpanel`、`aria-selected` 和关联 ID 提供完整可访问语义。
|
||||
- 每个分区的“来源字段”问号都展示字段引用、选择和数据补全规则;下方以两列复选框排列,每项同时展示中文名称和可在提示词中直接使用的字段名。“发送者昵称”另带可悬停和聚焦的问号,说明字段缺失时的省略行为。五个字段的语义仍以 5.1 节为准。
|
||||
- 每个分区的增强提示词标题左对齐,右邻问号帮助入口;使用说明、生效规则、隐私提示和对应示例均收纳在帮助框中。“填入示例”和“清空”为右侧轻量文字操作,下方只保留多行编辑区。
|
||||
- 底部操作右对齐:“取消”为次级按钮,“保存”为主按钮。
|
||||
|
||||
浅色和深色主题沿用项目配色,不为九个渠道分别定义外观。窄屏下按钮名称和最长状态标签均不可被挤压或截断;触控目标至少 44px,保留键盘焦点提示、弹窗焦点约束、Esc 取消及关闭后返回入口按钮的焦点行为。
|
||||
|
|
@ -255,8 +259,8 @@ conversationType是群聊时回复严肃一点,conversationType是私聊时回
|
|||
|
||||
字段约束:
|
||||
|
||||
- 新配置默认只选择 `senderId`,但在最终发送的 JSON 中五个字段均可省略,不把其中任何字段设为强制发送。
|
||||
- 最终字段集合为“用户已选字段”与“本条消息可用字段”的交集;未勾选的字段不发送,也不以空值或其他位置补回。
|
||||
- 群聊和私聊的新配置各自默认只选择 `senderId`,但在最终发送的 JSON 中五个字段均可省略,不把其中任何字段设为强制发送。
|
||||
- 最终字段集合为“当前会话场景已选字段”与“本条消息可用字段”的交集;当前场景未勾选的字段不发送,也不从另一场景或其他位置补回。
|
||||
- 字段选择只控制发给模型的内容,不删除插件内部用于路由、访问控制和启用范围判断的真实事件信息。
|
||||
- 不发送值为 `undefined`、`null` 或空字符串的 `senderName`。
|
||||
- 不使用 `senderId` 冒充 `senderName`。
|
||||
|
|
@ -401,35 +405,55 @@ conversationType是群聊时回复严肃一点,conversationType是私聊时回
|
|||
"agentPresets": {},
|
||||
"contextEnhancement": {
|
||||
"telegram_xxx": {
|
||||
"groupEnabled": true,
|
||||
"directEnabled": false,
|
||||
"group": {
|
||||
"enabled": true,
|
||||
"fields": ["channel", "conversationType", "senderId", "senderName", "botId"],
|
||||
"guidance": ""
|
||||
"guidance": "群聊提示词正文"
|
||||
},
|
||||
"direct": {
|
||||
"enabled": false,
|
||||
"fields": ["senderId"],
|
||||
"guidance": "私聊提示词正文"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上例表示仅群聊开启、五个字段全选、增强提示词已经显式清空。缺少某个机器人配置时使用以下默认值:
|
||||
上例表示仅群聊开启;群聊和私聊已保存不同的字段与提示词。缺少某个机器人配置时使用以下默认值:
|
||||
|
||||
| 配置项 | 类型 | 默认值与含义 |
|
||||
| --- | --- | --- |
|
||||
| `groupEnabled` | boolean | `false`,控制群聊 |
|
||||
| `directEnabled` | boolean | `false`,控制私聊 |
|
||||
| `fields` | 五个字段名组成的数组 | 默认仅选择 `senderId`;显式 `[]` 表示全部取消 |
|
||||
| `guidance` | string | 默认为空;非空时保存用户正文或填入的示例正文 |
|
||||
| `group` | object | 群聊完整配置 |
|
||||
| `direct` | object | 私聊完整配置 |
|
||||
| `group.enabled` / `direct.enabled` | boolean | 均为 `false` |
|
||||
| `group.fields` / `direct.fields` | 五个字段名组成的数组 | 均默认只选择 `senderId`;显式 `[]` 表示全部取消 |
|
||||
| `group.guidance` / `direct.guidance` | string | 均默认为空;非空时保存对应场景的用户正文或示例正文 |
|
||||
|
||||
持久化约束:
|
||||
|
||||
- 按机器人保存完整配置,两个开关、字段选择和提示词正文一起更新。
|
||||
- `guidance` 只保存正文,不保存插件自动添加的外围标签;空白正文可归一化为 `""`。
|
||||
- `fields: []` 和 `guidance: ""` 都是有效用户选择,不能当作缺失值回退到默认字段或示例正文。
|
||||
- 按机器人保存完整配置,`group` 与 `direct` 通过一次原子写入一起更新。
|
||||
- 两个 `guidance` 都只保存正文,不保存插件自动添加的外围标签;空白正文可归一化为 `""`。
|
||||
- 任一场景的 `fields: []` 和 `guidance: ""` 都是有效用户选择,不能当作缺失值回退到默认字段、另一场景或示例正文。
|
||||
- 只允许五个已定义的字段名;去重并按规范顺序保存,不接收任意额外元数据字段。
|
||||
- 两个开关都关闭时仍保留用户保存的字段和正文,不能以“关闭”为由删除配置。
|
||||
- 任一开关关闭时仍保留该场景保存的字段和正文,不能以“关闭”为由删除配置。
|
||||
- 配置缺失默认双关闭;配置损坏时安全降级为双关闭,不能因异常自动开启或扩大正在发送的字段范围。
|
||||
|
||||
实现时可以扩展现有 `BotWorkspaceStore` 所承载的机器人设置,但对外语义应保持为通用机器人配置,不能让渠道 Bridge 直接读写配置文件。
|
||||
|
||||
#### 8.1.1 旧配置自动迁移
|
||||
|
||||
旧版精确结构 `{ groupEnabled, directEnabled, fields, guidance }` 在升级后按以下规则兼容:
|
||||
|
||||
- `groupEnabled` 映射为 `group.enabled`,`directEnabled` 映射为 `direct.enabled`;
|
||||
- 旧 `fields` 无损复制到 `group.fields` 与 `direct.fields`;
|
||||
- 旧 `guidance` 无损复制到 `group.guidance` 与 `direct.guidance`;
|
||||
- 启动加载时只在内存中规范化,不因为读取而改写磁盘;机器人可立即按迁移结果继续运行;
|
||||
- 之后任意一次机器人设置成功写入时,复用 `BotWorkspaceStore` 现有原子持久化机制写成新结构,不增加一次性迁移器、版本文件或额外后台任务;
|
||||
- 新的保存 RPC 只接受新结构,避免旧、新或混合字段继续写入;结构损坏或混合时安全回退为双关闭默认值。
|
||||
|
||||
因此升级本身即可完成运行时迁移,用户无需手工操作;落盘采用惰性迁移,既保留旧配置内容,也避免仅启动或查看设置就产生文件改动。
|
||||
|
||||
### 8.2 Host RPC 与状态
|
||||
|
||||
九个渠道统一增加逻辑等价的设置接口,例如:
|
||||
|
|
@ -444,10 +468,16 @@ bot.context-enhancement.set
|
|||
{
|
||||
"botId": "telegram_xxx",
|
||||
"config": {
|
||||
"groupEnabled": true,
|
||||
"directEnabled": false,
|
||||
"group": {
|
||||
"enabled": true,
|
||||
"fields": ["channel", "conversationType"],
|
||||
"guidance": ""
|
||||
"guidance": "群聊提示词正文"
|
||||
},
|
||||
"direct": {
|
||||
"enabled": false,
|
||||
"fields": ["senderId"],
|
||||
"guidance": "私聊提示词正文"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -457,10 +487,16 @@ bot.context-enhancement.set
|
|||
```json
|
||||
{
|
||||
"contextEnhancement": {
|
||||
"groupEnabled": true,
|
||||
"directEnabled": false,
|
||||
"group": {
|
||||
"enabled": true,
|
||||
"fields": ["channel", "conversationType"],
|
||||
"guidance": ""
|
||||
"guidance": "群聊提示词正文"
|
||||
},
|
||||
"direct": {
|
||||
"enabled": false,
|
||||
"fields": ["senderId"],
|
||||
"guidance": "私聊提示词正文"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -469,17 +505,17 @@ bot.context-enhancement.set
|
|||
|
||||
- 保存时校验完整配置,不允许非法类型、未知字段名或超出约定长度的正文进入运行状态。
|
||||
- 保存请求失败不部分更新任意字段;成功响应返回实际生效的规范化配置,界面据此更新状态。
|
||||
- 清空通过提交 `guidance: ""` 表达;填入示例通过提交示例正文表达,不能依赖空字符串回退。
|
||||
- 清空通过提交对应场景的 `guidance: ""` 表达;填入示例通过提交对应示例正文表达,不能依赖空字符串回退或影响另一场景。
|
||||
- 保存的是 dsh-im 插件配置,不是向 Harness 新增的 RPC 或 `source` 参数。
|
||||
|
||||
### 8.3 Runtime 与 Bridge
|
||||
|
||||
- 生产装配层把当前机器人 ID 和整套上下文增强配置的读取能力显式传给 Runtime/Bridge。
|
||||
- 接收消息时取得内存中已提交配置的只读引用,先检查实际会话类型对应的 `groupEnabled` 或 `directEnabled` 是否严格为 `true`;无法可靠判断时按关闭处理。
|
||||
- 接收消息时取得内存中已提交配置的只读引用,根据实际会话类型只选择 `group` 或 `direct`,再检查该场景的 `enabled` 是否严格为 `true`;无法可靠判断时按关闭处理。
|
||||
- 对应开关未开启时立即跳过增强分支,继续原有链路;不访问或处理增强模板,不执行专用于增强的字段提取、投影、转义、序列化和内容重组。
|
||||
- 对应开关开启后,后续排队和装配使用接收时的同一完整配置快照,不能混用不同版本的开关、字段和正文。
|
||||
- 对应开关开启后,从已有元数据中筛选 `fields` 指定的可用字段,再安全序列化并生成非空来源块。
|
||||
- 根据 `guidance` 是否非空独立生成提示词块,并自动添加成对标签;不因清空正文自动填入示例。
|
||||
- 对应开关开启后,立即捕获当前场景的只读配置快照;后续排队和装配只使用这份快照,不能混用另一场景或不同版本的开关、字段和正文。
|
||||
- 从已有元数据中筛选当前场景 `fields` 指定的可用字段,再安全序列化并生成非空来源块。
|
||||
- 根据当前场景 `guidance` 是否非空生成提示词块,并自动添加成对标签;不回退另一场景,也不因清空正文自动填入示例。
|
||||
- 每条消息不读取磁盘,也不调用平台 API。
|
||||
- 点击“保存”只产生一次浏览器到本地 Host 的设置 RPC;正常消息仍只有现有的 Harness 提交请求。
|
||||
- 不应把这项配置伪装成 Harness 能力或加入 Harness 客户端协议。
|
||||
|
|
@ -489,7 +525,7 @@ bot.context-enhancement.set
|
|||
|
||||
九个渠道应复用:
|
||||
|
||||
- 一个机器人卡片设置入口及共享弹窗,包含群聊/私聊双开关、五字段选择、正文编辑、问号帮助、填入示例和清空操作;
|
||||
- 一个机器人卡片设置入口及共享弹窗,以两个 Tab 复用同一个场景编辑组件,维护群聊、私聊两份开关、五字段选择、正文编辑、问号帮助、填入示例和清空操作;
|
||||
- 一套设置存储和 RPC 行为;
|
||||
- 一套会话类型启用判断、字段白名单投影和空内容省略规则;
|
||||
- 一个来源数据校验与安全序列化函数;
|
||||
|
|
@ -556,7 +592,7 @@ Agent 或插件不能仅根据来源块:
|
|||
- 不改变 Agent Preset 的“只影响后续新 Session”语义。
|
||||
- 不改变群聊是否响应、谁可以访问机器人等现有规则。
|
||||
- 开启或关闭都不重新创建 Harness Session。
|
||||
- 插件升级后保留双开关、字段选择和正文,包括显式空字段列表与清空后的正文;机器人删除后不残留孤立配置。
|
||||
- 插件升级后按 8.1.1 节自动保留旧双开关,并把旧字段选择和正文分别复制到群聊、私聊,包括显式空字段列表与清空后的正文;机器人删除后不残留孤立配置。
|
||||
|
||||
### 10.3 曾经开启后的历史边界
|
||||
|
||||
|
|
@ -569,31 +605,32 @@ Agent 或插件不能仅根据来源块:
|
|||
### 11.1 UI 与配置
|
||||
|
||||
1. 九个渠道的每张机器人卡片都有“上下文增强”按钮,点击打开该机器人的设置弹窗,不直接启停。
|
||||
2. 弹窗按顶部双开关、中间五字段选择、底部增强提示词编辑的顺序展示。
|
||||
3. 新机器人和缺少该项配置的旧机器人,群聊、私聊开关默认均为关闭,来源字段默认只选 `senderId`,增强提示词默认留空;已有有效保存值保持不变。
|
||||
4. 群聊/私聊双开关四种组合均能独立保存,卡片摘要正确;仅支持私聊的渠道明确标注群聊不可用。
|
||||
5. 同渠道不同机器人、不同渠道机器人之间的整套配置互不影响。
|
||||
6. 取消或关闭弹窗不保存草稿;点击“保存”才原子提交,失败时运行配置不变且草稿仍可重试。
|
||||
7. 保存不触发机器人重连、Session 重建或当前回复中断;刷新页面和重启插件后配置保持。
|
||||
8. 编辑区只编辑正文;问号可通过悬停或键盘聚焦查看使用说明和示例;“清空”仅清除正文,“填入示例”仅填入示例正文,均不改变字段和开关。
|
||||
9. 清空并保存后,刷新、重启、关闭再开启都保持为空,不自动填入示例;空字段列表同样保持为空。
|
||||
10. 关闭某类会话保留字段和正文,删除机器人时一并删除全部配置。
|
||||
11. 按钮与弹窗符合 4.7 节已确认样式;灰色“未开启”仍可点击,开启后仅状态标签使用强调色。
|
||||
12. 浅色/深色主题及 320px、360px、常规桌面宽度下不重叠、不横向溢出,最长状态标签完整显示;键盘和触控均可操作。
|
||||
2. 弹窗依次以私聊、群聊两个 Tab 复用一个编辑区,默认打开私聊,一次只显示当前场景;切换后草稿不丢失,每个场景均包含自己的开关、五字段选择和增强提示词编辑。
|
||||
3. 新机器人和缺少该项配置的旧机器人,群聊、私聊均默认关闭、各自只选 `senderId`、提示词留空。
|
||||
4. 旧版共用配置升级后,两个开关分别保留,字段和提示词无损复制到两个场景;仅加载不改写磁盘,下一次成功写入机器人设置时自动保存为新结构。
|
||||
5. 群聊/私聊双开关四种组合均能原子保存,卡片摘要正确;仅支持私聊的渠道明确标注并禁用整个群聊区域,同时保留其数据。
|
||||
6. 同渠道不同机器人、不同渠道机器人之间的整套配置互不影响。
|
||||
7. 取消或关闭弹窗不保存草稿;点击“保存”才原子提交两套配置,失败时运行配置不变且草稿仍可重试。
|
||||
8. 保存不触发机器人重连、Session 重建或当前回复中断;刷新页面和重启插件后配置保持。
|
||||
9. 两个编辑区分别提供问号说明与对应示例;任一“清空”或“填入示例”只改变本场景草稿,不改变另一场景、字段或开关。
|
||||
10. 任一场景清空并保存后,刷新、重启、关闭再开启都保持为空,不自动填入示例;空字段列表同样保持为空。
|
||||
11. 关闭某类会话保留该场景的字段和正文,删除机器人时一并删除全部配置。
|
||||
12. 按钮与弹窗符合 4.7 节已确认样式;灰色“未开启”仍可点击,开启后仅状态标签使用强调色。
|
||||
13. 浅色/深色主题及 320px、360px、常规桌面宽度下不重叠、不横向溢出,最长状态标签完整显示;键盘和触控均可操作。
|
||||
|
||||
### 11.2 提示词
|
||||
|
||||
1. 当前会话类型关闭时,文本、图片和文件消息传给 Harness 的内容与当前版本一致,即使另一个会话类型已开启。
|
||||
2. 五个字段的全部 32 种选择组合均按白名单投影;未选字段不发送,勾选顺序不影响 JSON 键顺序。
|
||||
2. 群聊和私聊各自的五字段选择均按白名单投影;当前场景未选字段不发送,另一场景已选也不能补入,勾选顺序不影响 JSON 键顺序。
|
||||
3. 取消 `conversationType` 后,群聊/私聊启用判断仍根据真实事件正确执行,不因字段未发送而失效。
|
||||
4. `senderName` 缺失时完全省略;仅选择昵称但无昵称时不发送空来源块,不补查或伪造值。
|
||||
5. 正文非空时自动添加完整的 `<dsh_im_source_guidance>` 成对标签;内置模板与自定义模板使用同一规则。
|
||||
6. 正文清空或仅含空白时不附加 guidance 块、不回退默认,但来源块仍按字段选择发送。
|
||||
7. 字段全部取消且正文非空时只附加 guidance 块;二者都为空时原消息保持不变。
|
||||
5. 当前场景正文非空时自动添加完整的 `<dsh_im_source_guidance>` 成对标签;对应内置模板与自定义模板使用同一规则,另一场景正文不得出现。
|
||||
6. 当前场景正文清空或仅含空白时不附加 guidance 块,不回退默认或另一场景;来源块仍按当前场景字段选择发送。
|
||||
7. 当前场景字段全部取消且正文非空时只附加 guidance 块;二者都为空时原消息保持不变,另一场景配置不参与。
|
||||
8. 多模态消息只在有增强内容时增加一个前置文本项,不生成空项;文件清单、图片和文件内容保持原有顺序与内容。
|
||||
9. 插件本地命令、Harness 控制交互和被过滤消息不附加任何增强块。
|
||||
10. 同名标签出现在模板正文中不能破坏外围封装;插件每次最多附加一个来源块和一个提示词块,不改变用户原文。
|
||||
11. 每条消息使用接收时已提交的同一配置快照;关闭后只影响后续消息,现有 Session 和历史不变。
|
||||
11. 每条消息使用接收时已提交的当前场景配置快照;群聊提示词和字段不得出现在私聊,私聊提示词和字段不得出现在群聊;关闭后只影响后续消息,现有 Session 和历史不变。
|
||||
|
||||
### 11.3 九渠道与网络边界
|
||||
|
||||
|
|
@ -602,7 +639,7 @@ Agent 或插件不能仅根据来源块:
|
|||
3. 企业微信、微信、飞书、Slack 验证不会为昵称发起补查。
|
||||
4. 测试应断言普通消息处理没有新增平台 API 调用。
|
||||
5. 群聊连续由不同用户发言时,选中的 `senderId` 和可用昵称正确变化;未选发送者字段时不附加这些字段。
|
||||
6. 同时开启多个机器人时,各自的双开关、所选字段、可选 `botId` 和提示词正文正确,不串用配置。
|
||||
6. 同时开启多个机器人时,各机器人、各会话场景的开关、所选字段、可选 `botId` 和提示词正文正确,不串用配置。
|
||||
|
||||
### 11.4 安全与异常
|
||||
|
||||
|
|
@ -627,12 +664,12 @@ Agent 或插件不能仅根据来源块:
|
|||
|
||||
## 12. 建议实施顺序
|
||||
|
||||
1. 增加公共配置字段、迁移兼容、状态投影和设置 RPC。
|
||||
2. 增加双开关判断、字段选择投影、空正文规则、安全序列化和提示词装配函数。
|
||||
1. 把公共配置改为 `group`、`direct` 两个同构对象,复用现有设置存储增加惰性迁移、状态投影和设置 RPC 校验。
|
||||
2. 按真实会话类型选择一套配置,继续复用现有字段投影、空正文规则、安全序列化和提示词装配函数。
|
||||
3. 为九个渠道补齐入站事件字段提取,保留事件中已经存在但当前被丢弃的昵称。
|
||||
4. 在共享 `TextHarnessBridge` 和五个独立 Bridge 中接入统一装配函数。
|
||||
5. 增加共享机器人卡片设置入口与弹窗,接入双开关、字段选择、正文编辑、清空及保存,并覆盖九渠道卡片。
|
||||
6. 完成公共契约测试、九渠道 Fixture 测试、UI 测试和不新增平台请求的回归测试;11.5 节关闭状态对照回归必须全部通过。
|
||||
7. 在至少一个私聊渠道和一个群聊渠道进行真实客户端验收,再逐步覆盖九渠道。
|
||||
5. 复用一个场景编辑组件,在现有共享弹窗通过两个 Tab 切换群聊和私聊配置;保留原子保存、草稿、帮助、清空与九渠道入口。
|
||||
6. 完成新旧配置契约、群聊/私聊互不泄漏、九渠道 Fixture、UI 和不新增平台请求的回归测试;11.5 节关闭状态对照回归必须全部通过。
|
||||
7. 使用同一个飞书机器人分别进行私聊与指定群聊真实验收,并在结束后恢复测试前配置。
|
||||
|
||||
整个实施过程中,Harness 代码和 Harness 协议保持不变。
|
||||
|
|
|
|||
537
lib/client.js
537
lib/client.js
|
|
@ -1050,31 +1050,44 @@ var EN = Object.freeze({
|
|||
"\u7FA4\u804A\u548C\u79C1\u804A": "Groups and direct",
|
||||
"\u5173\u95ED\u5F39\u7A97": "Close dialog",
|
||||
"\u67E5\u770B\u4E0A\u4E0B\u6587\u589E\u5F3A\u8BF4\u660E": "View context enhancement details",
|
||||
"\u4E0A\u4E0B\u6587\u589E\u5F3A\u8303\u56F4": "Context enhancement scope",
|
||||
"\u542F\u7528\u8303\u56F4": "Enable in",
|
||||
"\u7FA4\u804A\u4E2D\u542F\u7528": "Enable in group chats",
|
||||
"\u79C1\u804A\u4E2D\u542F\u7528": "Enable in direct chats",
|
||||
"\u542F\u7528": "Enabled",
|
||||
"\uFF08\u5F53\u524D\u6E20\u9053\u4E0D\u652F\u6301\u7FA4\u804A\uFF09": "(This channel does not support group chats)",
|
||||
"\u6765\u6E90\u5B57\u6BB5": "Source fields",
|
||||
"\u67E5\u770B\u6765\u6E90\u5B57\u6BB5\u8BF4\u660E": "View source field details",
|
||||
"\u67E5\u770B\u7FA4\u804A\u6765\u6E90\u5B57\u6BB5\u8BF4\u660E": "View group source field details",
|
||||
"\u67E5\u770B\u79C1\u804A\u6765\u6E90\u5B57\u6BB5\u8BF4\u660E": "View direct source field details",
|
||||
"\u6E20\u9053": "Channel",
|
||||
"\u4F1A\u8BDD\u7C7B\u578B": "Conversation type",
|
||||
"\u53D1\u9001\u8005\u6807\u8BC6": "Sender ID",
|
||||
"\u53D1\u9001\u8005\u6635\u79F0": "Sender name",
|
||||
"\u67E5\u770B\u53D1\u9001\u8005\u6635\u79F0\u5B57\u6BB5\u8BF4\u660E": "View sender name field details",
|
||||
"\u67E5\u770B\u7FA4\u804A\u53D1\u9001\u8005\u6635\u79F0\u5B57\u6BB5\u8BF4\u660E": "View group sender name field details",
|
||||
"\u67E5\u770B\u79C1\u804A\u53D1\u9001\u8005\u6635\u79F0\u5B57\u6BB5\u8BF4\u660E": "View direct sender name field details",
|
||||
"\u8BE5\u5B57\u6BB5\u4E0D\u662F\u6BCF\u4E2A\u6E20\u9053\u90FD\u80FD\u63D0\u4F9B\u3002\u5F53\u524D\u6D88\u606F\u6CA1\u6709\u53D1\u9001\u8005\u6635\u79F0\u65F6\uFF0C\u5373\u4F7F\u5DF2\u9009\u62E9\u8BE5\u5B57\u6BB5\uFF0C<dsh_im_source> \u4E2D\u4E5F\u4F1A\u7701\u7565 senderName\u3002": "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.",
|
||||
"\u673A\u5668\u4EBA\u6807\u8BC6": "Bot ID",
|
||||
"\u589E\u5F3A\u63D0\u793A\u8BCD": "Guidance",
|
||||
"\u67E5\u770B\u589E\u5F3A\u63D0\u793A\u8BCD\u4F7F\u7528\u8BF4\u660E": "View guidance instructions",
|
||||
"\u67E5\u770B\u7FA4\u804A\u589E\u5F3A\u63D0\u793A\u8BCD\u4F7F\u7528\u8BF4\u660E": "View group guidance instructions",
|
||||
"\u67E5\u770B\u79C1\u804A\u589E\u5F3A\u63D0\u793A\u8BCD\u4F7F\u7528\u8BF4\u660E": "View direct guidance instructions",
|
||||
"\u4F7F\u7528\u8BF4\u660E": "How to use",
|
||||
"\u7528\u4E8E\u544A\u8BC9\u6A21\u578B\u5982\u4F55\u4F7F\u7528 <dsh_im_source> \u4E2D\u5DF2\u9009\u62E9\u7684\u6765\u6E90\u5B57\u6BB5\u3002\u53EA\u586B\u5199\u6B63\u6587\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u6DFB\u52A0 <dsh_im_source_guidance> \u6210\u5BF9\u6807\u7B7E\u3002": "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.",
|
||||
"\u751F\u6548\u89C4\u5219": "Behavior",
|
||||
"\u9690\u79C1\u63D0\u793A": "Privacy",
|
||||
"\u4F7F\u7528\u793A\u4F8B": "Example",
|
||||
"\u4EC5\u4F9D\u636E\u5F53\u524D\u6D88\u606F\u7684 <dsh_im_source> \u4E2D\u5B9E\u9645\u63D0\u4F9B\u7684\u5B57\u6BB5\u7406\u89E3\u6765\u6E90\uFF1B\u6CA1\u6709\u63D0\u4F9B\u7684\u5B57\u6BB5\u4E0D\u8981\u731C\u6D4B\u6216\u8865\u5168\u3002\nconversationType\u662F\u7FA4\u804A\u65F6\u56DE\u590D\u4E25\u8083\u4E00\u70B9\uFF0CconversationType\u662F\u79C1\u804A\u65F6\u56DE\u590D\u4E00\u5B9A\u8981\u5E7D\u9ED8\u641E\u7B11\uFF0C\u50CF\u5468\u661F\u9A70\u7684\u7535\u5F71\u4E00\u6837\u641E\u7B11": "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.",
|
||||
"\u4EC5\u4F9D\u636E\u5F53\u524D\u6D88\u606F\u7684 <dsh_im_source> \u4E2D\u5B9E\u9645\u63D0\u4F9B\u7684\u5B57\u6BB5\u7406\u89E3\u6765\u6E90\uFF1B\u6CA1\u6709\u63D0\u4F9B\u7684\u5B57\u6BB5\u4E0D\u8981\u731C\u6D4B\u6216\u8865\u5168\u3002\n\u5F53\u524D\u6D88\u606F\u6765\u81EA\u7FA4\u804A\uFF0C\u8BF7\u4F7F\u7528\u4E25\u8083\u3001\u514B\u5236\u3001\u7B80\u6D01\u7684\u8868\u8FBE\u65B9\u5F0F\u3002": "Understand the source only from fields actually present in <dsh_im_source> for the current message; do not guess or fill in missing fields.\nThis message is from a group chat. Use a serious, restrained, and concise style.",
|
||||
"\u4EC5\u4F9D\u636E\u5F53\u524D\u6D88\u606F\u7684 <dsh_im_source> \u4E2D\u5B9E\u9645\u63D0\u4F9B\u7684\u5B57\u6BB5\u7406\u89E3\u6765\u6E90\uFF1B\u6CA1\u6709\u63D0\u4F9B\u7684\u5B57\u6BB5\u4E0D\u8981\u731C\u6D4B\u6216\u8865\u5168\u3002\n\u5F53\u524D\u6D88\u606F\u6765\u81EA\u79C1\u804A\uFF0C\u53EF\u4EE5\u4F7F\u7528\u66F4\u8F7B\u677E\u3001\u5E7D\u9ED8\u3001\u8BE6\u7EC6\u7684\u8868\u8FBE\u65B9\u5F0F\u3002": "Understand the source only from fields actually present in <dsh_im_source> for the current message; do not guess or fill in missing fields.\nThis message is from a direct chat. You may use a lighter, more humorous, and detailed style.",
|
||||
"\u586B\u5165\u793A\u4F8B": "Use example",
|
||||
"\u6E05\u7A7A": "Clear",
|
||||
"\u9009\u62E9\u5728\u54EA\u4E9B\u4F1A\u8BDD\u4E2D\u542F\u7528\u3001\u63D0\u4F9B\u54EA\u4E9B\u6765\u6E90\u5B57\u6BB5\uFF0C\u4EE5\u53CA\u5982\u4F55\u4F7F\u7528\u8FD9\u4E9B\u4FE1\u606F\u3002\u4EC5\u4F7F\u7528\u5DF2\u6709\u6D88\u606F\u5143\u6570\u636E\uFF0C\u4E0D\u67E5\u8BE2\u5E73\u53F0 API\u3002": "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.",
|
||||
"\u589E\u5F3A\u63D0\u793A\u8BCD\u4E2D\u8BF7\u4F7F\u7528\u5B57\u6BB5\u540D\uFF08\u5982 senderId\u3001conversationType\uFF09\u5F15\u7528\u8FD9\u4E9B\u4FE1\u606F\u3002\u53EA\u53D1\u9001\u52FE\u9009\u4E14\u5F53\u524D\u6D88\u606F\u4E2D\u53EF\u7528\u7684\u5B57\u6BB5\uFF0C\u4E0D\u4F1A\u989D\u5916\u67E5\u8BE2\u6216\u8865\u5168\u3002": "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.",
|
||||
"\u589E\u5F3A\u63D0\u793A\u8BCD\u4E2D\u8BF7\u4F7F\u7528\u5B57\u6BB5\u540D\uFF08\u5982 senderId\u3001conversationType\uFF09\u5F15\u7528\u8FD9\u4E9B\u4FE1\u606F\u3002\u53EA\u53D1\u9001\u5F53\u524D\u4F1A\u8BDD\u4E2D\u52FE\u9009\u4E14\u53EF\u7528\u7684\u5B57\u6BB5\uFF0C\u4E0D\u4F1A\u989D\u5916\u67E5\u8BE2\u6216\u8865\u5168\u3002": "Reference these values by field name (such as senderId or conversationType) in the guidance. Only fields selected and available in the current conversation are sent; no extra lookups are made.",
|
||||
"\u7528\u4E8E\u544A\u8BC9\u6A21\u578B\u5982\u4F55\u4F7F\u7528\u5F53\u524D\u7FA4\u804A\u6D88\u606F\u7684 <dsh_im_source> \u6765\u6E90\u5B57\u6BB5\u3002\u53EA\u586B\u5199\u6B63\u6587\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u6DFB\u52A0 <dsh_im_source_guidance> \u6210\u5BF9\u6807\u7B7E\u3002": "Tell the model how to use <dsh_im_source> fields for the current group message. Enter only the body; the plugin adds paired <dsh_im_source_guidance> tags.",
|
||||
"\u7528\u4E8E\u544A\u8BC9\u6A21\u578B\u5982\u4F55\u4F7F\u7528\u5F53\u524D\u79C1\u804A\u6D88\u606F\u7684 <dsh_im_source> \u6765\u6E90\u5B57\u6BB5\u3002\u53EA\u586B\u5199\u6B63\u6587\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u6DFB\u52A0 <dsh_im_source_guidance> \u6210\u5BF9\u6807\u7B7E\u3002": "Tell the model how to use <dsh_im_source> fields for the current direct message. Enter only the body; the plugin adds paired <dsh_im_source_guidance> tags.",
|
||||
"\u4EC5\u5728\u7FA4\u804A\u5F00\u5173\u5F00\u542F\u65F6\u4F7F\u7528\u3002\u6E05\u7A7A\u5E76\u4FDD\u5B58\u540E\u4E0D\u518D\u9644\u52A0\u7FA4\u804A\u589E\u5F3A\u63D0\u793A\u8BCD\uFF1B\u6240\u9009\u6765\u6E90\u5B57\u6BB5\u4ECD\u6309\u5F53\u524D\u573A\u666F\u8BBE\u7F6E\u53D1\u9001\u3002": "Used only when group enhancement is enabled. Clear and save to omit group guidance; selected source fields still follow the group settings.",
|
||||
"\u4EC5\u5728\u79C1\u804A\u5F00\u5173\u5F00\u542F\u65F6\u4F7F\u7528\u3002\u6E05\u7A7A\u5E76\u4FDD\u5B58\u540E\u4E0D\u518D\u9644\u52A0\u79C1\u804A\u589E\u5F3A\u63D0\u793A\u8BCD\uFF1B\u6240\u9009\u6765\u6E90\u5B57\u6BB5\u4ECD\u6309\u5F53\u524D\u573A\u666F\u8BBE\u7F6E\u53D1\u9001\u3002": "Used only when direct enhancement is enabled. Clear and save to omit direct guidance; selected source fields still follow the direct settings.",
|
||||
"\u53EA\u9700\u586B\u5199\u6B63\u6587\uFF0C\u63D2\u4EF6\u81EA\u52A8\u6DFB\u52A0 <dsh_im_source_guidance> \u6210\u5BF9\u6807\u7B7E\u3002\u6E05\u7A7A\u5E76\u4FDD\u5B58\u540E\uFF0C\u4E0D\u518D\u9644\u52A0\u589E\u5F3A\u63D0\u793A\u8BCD\uFF0C\u5DF2\u9009\u6765\u6E90\u5B57\u6BB5\u4ECD\u6309\u5F00\u5173\u8BBE\u7F6E\u53D1\u9001\u3002": "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.",
|
||||
"\u53D1\u9001\u8005\u6807\u8BC6\u53EF\u80FD\u5305\u542B\u5E73\u53F0\u7528\u6237 ID \u6216\u7535\u8BDD\u53F7\u7801\u5F62\u5F0F\u7684\u6807\u8BC6\u3002\u5173\u95ED\u5F00\u5173\u4E0D\u4F1A\u5220\u9664\u5DF2\u7ECF\u5199\u5165\u4F1A\u8BDD\u5386\u53F2\u7684\u4FE1\u606F\u3002": "Sender IDs may contain platform user IDs or phone-number-like identifiers. Turning this off does not remove information already saved in conversation history.",
|
||||
"\u4E0A\u4E0B\u6587\u589E\u5F3A\u4FDD\u5B58\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002": "Could not save context enhancement. Try again.",
|
||||
|
|
@ -1881,26 +1894,39 @@ var CONTEXT_ENHANCEMENT_FIELDS = Object.freeze([
|
|||
"botId"
|
||||
]);
|
||||
var CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH = 8e3;
|
||||
var CONTEXT_GUIDANCE_EXAMPLE = `\u4EC5\u4F9D\u636E\u5F53\u524D\u6D88\u606F\u7684 <dsh_im_source> \u4E2D\u5B9E\u9645\u63D0\u4F9B\u7684\u5B57\u6BB5\u7406\u89E3\u6765\u6E90\uFF1B\u6CA1\u6709\u63D0\u4F9B\u7684\u5B57\u6BB5\u4E0D\u8981\u731C\u6D4B\u6216\u8865\u5168\u3002
|
||||
conversationType\u662F\u7FA4\u804A\u65F6\u56DE\u590D\u4E25\u8083\u4E00\u70B9\uFF0CconversationType\u662F\u79C1\u804A\u65F6\u56DE\u590D\u4E00\u5B9A\u8981\u5E7D\u9ED8\u641E\u7B11\uFF0C\u50CF\u5468\u661F\u9A70\u7684\u7535\u5F71\u4E00\u6837\u641E\u7B11`;
|
||||
var CONTEXT_GROUP_GUIDANCE_EXAMPLE = `\u4EC5\u4F9D\u636E\u5F53\u524D\u6D88\u606F\u7684 <dsh_im_source> \u4E2D\u5B9E\u9645\u63D0\u4F9B\u7684\u5B57\u6BB5\u7406\u89E3\u6765\u6E90\uFF1B\u6CA1\u6709\u63D0\u4F9B\u7684\u5B57\u6BB5\u4E0D\u8981\u731C\u6D4B\u6216\u8865\u5168\u3002
|
||||
\u5F53\u524D\u6D88\u606F\u6765\u81EA\u7FA4\u804A\uFF0C\u8BF7\u4F7F\u7528\u4E25\u8083\u3001\u514B\u5236\u3001\u7B80\u6D01\u7684\u8868\u8FBE\u65B9\u5F0F\u3002`;
|
||||
var CONTEXT_DIRECT_GUIDANCE_EXAMPLE = `\u4EC5\u4F9D\u636E\u5F53\u524D\u6D88\u606F\u7684 <dsh_im_source> \u4E2D\u5B9E\u9645\u63D0\u4F9B\u7684\u5B57\u6BB5\u7406\u89E3\u6765\u6E90\uFF1B\u6CA1\u6709\u63D0\u4F9B\u7684\u5B57\u6BB5\u4E0D\u8981\u731C\u6D4B\u6216\u8865\u5168\u3002
|
||||
\u5F53\u524D\u6D88\u606F\u6765\u81EA\u79C1\u804A\uFF0C\u53EF\u4EE5\u4F7F\u7528\u66F4\u8F7B\u677E\u3001\u5E7D\u9ED8\u3001\u8BE6\u7EC6\u7684\u8868\u8FBE\u65B9\u5F0F\u3002`;
|
||||
var DEFAULT_CONTEXT_ENHANCEMENT_CONFIG = Object.freeze({
|
||||
groupEnabled: false,
|
||||
directEnabled: false,
|
||||
group: Object.freeze({
|
||||
enabled: false,
|
||||
fields: Object.freeze(["senderId"]),
|
||||
guidance: ""
|
||||
}),
|
||||
direct: Object.freeze({
|
||||
enabled: false,
|
||||
fields: Object.freeze(["senderId"]),
|
||||
guidance: ""
|
||||
})
|
||||
});
|
||||
var CONFIG_KEYS = ["groupEnabled", "directEnabled", "fields", "guidance"];
|
||||
var CONFIG_KEYS = ["group", "direct"];
|
||||
var SCOPE_KEYS = ["enabled", "fields", "guidance"];
|
||||
var LEGACY_CONFIG_KEYS = ["groupEnabled", "directEnabled", "fields", "guidance"];
|
||||
function invalidConfig(message) {
|
||||
const error = new TypeError(message);
|
||||
error.code = "context-enhancement-invalid";
|
||||
return error;
|
||||
}
|
||||
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))) {
|
||||
function hasExactKeys(input, keys) {
|
||||
return input && typeof input === "object" && !Array.isArray(input) && [Object.prototype, null].includes(Object.getPrototypeOf(input)) && Reflect.ownKeys(input).length === keys.length && keys.every((key) => Object.hasOwn(input, key));
|
||||
}
|
||||
function validateContextEnhancementScope(input) {
|
||||
if (!hasExactKeys(input, SCOPE_KEYS)) {
|
||||
throw invalidConfig("\u8BF7\u63D0\u4EA4\u5B8C\u6574\u7684\u4E0A\u4E0B\u6587\u589E\u5F3A\u8BBE\u7F6E\u3002");
|
||||
}
|
||||
const { groupEnabled, directEnabled, fields, guidance } = input;
|
||||
if (typeof groupEnabled !== "boolean" || typeof directEnabled !== "boolean") {
|
||||
const { enabled, fields, guidance } = input;
|
||||
if (typeof enabled !== "boolean") {
|
||||
throw invalidConfig("\u7FA4\u804A\u548C\u79C1\u804A\u5F00\u5173\u5FC5\u987B\u662F\u5E03\u5C14\u503C\u3002");
|
||||
}
|
||||
if (!Array.isArray(fields) || ![...fields].every((field) => CONTEXT_ENHANCEMENT_FIELDS.includes(field))) {
|
||||
|
|
@ -1910,19 +1936,48 @@ function validateContextEnhancementConfig(input) {
|
|||
throw invalidConfig(`\u589E\u5F3A\u63D0\u793A\u8BCD\u4E0D\u5F97\u8D85\u8FC7 ${CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH} \u4E2A\u5B57\u7B26\u3002`);
|
||||
}
|
||||
return Object.freeze({
|
||||
groupEnabled,
|
||||
directEnabled,
|
||||
enabled,
|
||||
fields: Object.freeze(CONTEXT_ENHANCEMENT_FIELDS.filter((field) => fields.includes(field))),
|
||||
guidance: guidance.trim() ? guidance : ""
|
||||
});
|
||||
}
|
||||
function validateContextEnhancementConfig(input) {
|
||||
if (!hasExactKeys(input, CONFIG_KEYS)) {
|
||||
throw invalidConfig("\u8BF7\u63D0\u4EA4\u5B8C\u6574\u7684\u4E0A\u4E0B\u6587\u589E\u5F3A\u8BBE\u7F6E\u3002");
|
||||
}
|
||||
return Object.freeze({
|
||||
group: validateContextEnhancementScope(input.group),
|
||||
direct: validateContextEnhancementScope(input.direct)
|
||||
});
|
||||
}
|
||||
function migrateLegacyContextEnhancementConfig(input) {
|
||||
if (!hasExactKeys(input, LEGACY_CONFIG_KEYS)) {
|
||||
throw invalidConfig("\u8BF7\u63D0\u4EA4\u5B8C\u6574\u7684\u4E0A\u4E0B\u6587\u589E\u5F3A\u8BBE\u7F6E\u3002");
|
||||
}
|
||||
return validateContextEnhancementConfig({
|
||||
group: {
|
||||
enabled: input.groupEnabled,
|
||||
fields: input.fields,
|
||||
guidance: input.guidance
|
||||
},
|
||||
direct: {
|
||||
enabled: input.directEnabled,
|
||||
fields: input.fields,
|
||||
guidance: input.guidance
|
||||
}
|
||||
});
|
||||
}
|
||||
function normalizeContextEnhancementConfig(input) {
|
||||
try {
|
||||
return validateContextEnhancementConfig(input);
|
||||
} catch {
|
||||
try {
|
||||
return migrateLegacyContextEnhancementConfig(input);
|
||||
} catch {
|
||||
return DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// plugin-src/client/channels/dingtalk/api.js
|
||||
var DINGTALK_RPC_CHANNEL = "/dingtalk";
|
||||
|
|
@ -2656,11 +2711,33 @@ var FIELD_LABELS = Object.freeze({
|
|||
senderName: "\u53D1\u9001\u8005\u6635\u79F0",
|
||||
botId: "\u673A\u5668\u4EBA\u6807\u8BC6"
|
||||
});
|
||||
var SCOPE_COPY = Object.freeze({
|
||||
group: Object.freeze({
|
||||
title: "\u7FA4\u804A",
|
||||
enable: "\u542F\u7528",
|
||||
fieldsHelpLabel: "\u67E5\u770B\u7FA4\u804A\u6765\u6E90\u5B57\u6BB5\u8BF4\u660E",
|
||||
senderNameHelpLabel: "\u67E5\u770B\u7FA4\u804A\u53D1\u9001\u8005\u6635\u79F0\u5B57\u6BB5\u8BF4\u660E",
|
||||
guidanceLabel: "\u589E\u5F3A\u63D0\u793A\u8BCD",
|
||||
guidanceHelpLabel: "\u67E5\u770B\u7FA4\u804A\u589E\u5F3A\u63D0\u793A\u8BCD\u4F7F\u7528\u8BF4\u660E",
|
||||
guidanceUsage: "\u7528\u4E8E\u544A\u8BC9\u6A21\u578B\u5982\u4F55\u4F7F\u7528\u5F53\u524D\u7FA4\u804A\u6D88\u606F\u7684 <dsh_im_source> \u6765\u6E90\u5B57\u6BB5\u3002\u53EA\u586B\u5199\u6B63\u6587\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u6DFB\u52A0 <dsh_im_source_guidance> \u6210\u5BF9\u6807\u7B7E\u3002",
|
||||
guidanceBehavior: "\u4EC5\u5728\u7FA4\u804A\u5F00\u5173\u5F00\u542F\u65F6\u4F7F\u7528\u3002\u6E05\u7A7A\u5E76\u4FDD\u5B58\u540E\u4E0D\u518D\u9644\u52A0\u7FA4\u804A\u589E\u5F3A\u63D0\u793A\u8BCD\uFF1B\u6240\u9009\u6765\u6E90\u5B57\u6BB5\u4ECD\u6309\u5F53\u524D\u573A\u666F\u8BBE\u7F6E\u53D1\u9001\u3002"
|
||||
}),
|
||||
direct: Object.freeze({
|
||||
title: "\u79C1\u804A",
|
||||
enable: "\u542F\u7528",
|
||||
fieldsHelpLabel: "\u67E5\u770B\u79C1\u804A\u6765\u6E90\u5B57\u6BB5\u8BF4\u660E",
|
||||
senderNameHelpLabel: "\u67E5\u770B\u79C1\u804A\u53D1\u9001\u8005\u6635\u79F0\u5B57\u6BB5\u8BF4\u660E",
|
||||
guidanceLabel: "\u589E\u5F3A\u63D0\u793A\u8BCD",
|
||||
guidanceHelpLabel: "\u67E5\u770B\u79C1\u804A\u589E\u5F3A\u63D0\u793A\u8BCD\u4F7F\u7528\u8BF4\u660E",
|
||||
guidanceUsage: "\u7528\u4E8E\u544A\u8BC9\u6A21\u578B\u5982\u4F55\u4F7F\u7528\u5F53\u524D\u79C1\u804A\u6D88\u606F\u7684 <dsh_im_source> \u6765\u6E90\u5B57\u6BB5\u3002\u53EA\u586B\u5199\u6B63\u6587\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u6DFB\u52A0 <dsh_im_source_guidance> \u6210\u5BF9\u6807\u7B7E\u3002",
|
||||
guidanceBehavior: "\u4EC5\u5728\u79C1\u804A\u5F00\u5173\u5F00\u542F\u65F6\u4F7F\u7528\u3002\u6E05\u7A7A\u5E76\u4FDD\u5B58\u540E\u4E0D\u518D\u9644\u52A0\u79C1\u804A\u589E\u5F3A\u63D0\u793A\u8BCD\uFF1B\u6240\u9009\u6765\u6E90\u5B57\u6BB5\u4ECD\u6309\u5F53\u524D\u573A\u666F\u8BBE\u7F6E\u53D1\u9001\u3002"
|
||||
})
|
||||
});
|
||||
function contextEnhancementLabel(config) {
|
||||
const { groupEnabled, directEnabled } = normalizeContextEnhancementConfig(config);
|
||||
if (groupEnabled && directEnabled) return "\u7FA4\u804A\u548C\u79C1\u804A";
|
||||
if (groupEnabled) return "\u4EC5\u7FA4\u804A";
|
||||
if (directEnabled) return "\u4EC5\u79C1\u804A";
|
||||
const { group, direct } = normalizeContextEnhancementConfig(config);
|
||||
if (group.enabled && direct.enabled) return "\u7FA4\u804A\u548C\u79C1\u804A";
|
||||
if (group.enabled) return "\u4EC5\u7FA4\u804A";
|
||||
if (direct.enabled) return "\u4EC5\u79C1\u804A";
|
||||
return "\u672A\u5F00\u542F";
|
||||
}
|
||||
function ContextIcon({ kind = "sliders" }) {
|
||||
|
|
@ -2678,26 +2755,197 @@ function ContextIcon({ kind = "sliders" }) {
|
|||
focusable: "false"
|
||||
}, h2("path", { d: path }));
|
||||
}
|
||||
function ContextEnhancementScopeEditor({
|
||||
kind,
|
||||
scope,
|
||||
example,
|
||||
supported,
|
||||
busy,
|
||||
idPrefix,
|
||||
onChange
|
||||
}) {
|
||||
const copy = SCOPE_COPY[kind];
|
||||
const unavailableId = `${idPrefix}-${kind}-unavailable`;
|
||||
const fieldsHelpId = `${idPrefix}-${kind}-fields-help`;
|
||||
const senderNameHelpId = `${idPrefix}-${kind}-sender-name-help`;
|
||||
const guidanceId = `${idPrefix}-${kind}-guidance`;
|
||||
const guidanceHelpId = `${idPrefix}-${kind}-guidance-help`;
|
||||
const disabled = busy || !supported;
|
||||
return h2(
|
||||
"fieldset",
|
||||
{
|
||||
className: "dim-contextSection dim-contextScope",
|
||||
"data-context-kind": kind,
|
||||
"aria-label": copy.title,
|
||||
disabled
|
||||
},
|
||||
h2(
|
||||
"label",
|
||||
{ className: "dim-contextSwitchRow" },
|
||||
h2(
|
||||
"span",
|
||||
{ className: "dim-contextSwitchLabel" },
|
||||
h2("span", null, copy.enable),
|
||||
!supported ? h2("span", {
|
||||
id: unavailableId,
|
||||
className: "dim-contextUnavailable"
|
||||
}, "\uFF08\u5F53\u524D\u6E20\u9053\u4E0D\u652F\u6301\u7FA4\u804A\uFF09") : null
|
||||
),
|
||||
h2("input", {
|
||||
type: "checkbox",
|
||||
role: "switch",
|
||||
className: "dim-contextSwitch",
|
||||
checked: supported && scope.enabled,
|
||||
disabled,
|
||||
"aria-describedby": !supported ? unavailableId : void 0,
|
||||
onChange: (event) => {
|
||||
if (supported) onChange("enabled", event.target.checked);
|
||||
}
|
||||
})
|
||||
),
|
||||
h2(
|
||||
"div",
|
||||
{ className: "dim-contextScopeBlock" },
|
||||
h2(
|
||||
"div",
|
||||
{ className: "dim-contextLegend" },
|
||||
h2("span", null, "\u6765\u6E90\u5B57\u6BB5"),
|
||||
h2(
|
||||
"span",
|
||||
{ className: "dim-contextHelp dim-contextLegendHelp" },
|
||||
h2("button", {
|
||||
type: "button",
|
||||
className: "dim-contextHelpButton",
|
||||
disabled,
|
||||
"aria-label": copy.fieldsHelpLabel,
|
||||
"aria-describedby": fieldsHelpId
|
||||
}, h2("span", { "aria-hidden": "true" }, "?")),
|
||||
h2(
|
||||
"span",
|
||||
{ id: fieldsHelpId, className: "dim-contextTooltip dim-contextLegendTooltip", role: "tooltip" },
|
||||
"\u589E\u5F3A\u63D0\u793A\u8BCD\u4E2D\u8BF7\u4F7F\u7528\u5B57\u6BB5\u540D\uFF08\u5982 senderId\u3001conversationType\uFF09\u5F15\u7528\u8FD9\u4E9B\u4FE1\u606F\u3002\u53EA\u53D1\u9001\u5F53\u524D\u4F1A\u8BDD\u4E2D\u52FE\u9009\u4E14\u53EF\u7528\u7684\u5B57\u6BB5\uFF0C\u4E0D\u4F1A\u989D\u5916\u67E5\u8BE2\u6216\u8865\u5168\u3002"
|
||||
)
|
||||
)
|
||||
),
|
||||
h2("div", { className: "dim-contextFields" }, CONTEXT_ENHANCEMENT_FIELDS.map((field) => {
|
||||
const fieldId = `${idPrefix}-${kind}-field-${field}`;
|
||||
return h2(
|
||||
"div",
|
||||
{ key: field, className: "dim-contextField" },
|
||||
h2("input", {
|
||||
id: fieldId,
|
||||
type: "checkbox",
|
||||
name: `${kind}-${field}`,
|
||||
checked: scope.fields.includes(field),
|
||||
disabled,
|
||||
onChange: (event) => onChange("fields", event.target.checked ? [...scope.fields, field] : scope.fields.filter((value) => value !== field))
|
||||
}),
|
||||
h2(
|
||||
"span",
|
||||
{ className: "dim-contextFieldText" },
|
||||
h2("label", { className: "dim-contextFieldName", htmlFor: fieldId }, FIELD_LABELS[field]),
|
||||
field === "senderName" ? h2(
|
||||
"span",
|
||||
{ className: "dim-contextHelp dim-contextFieldHelp" },
|
||||
h2("button", {
|
||||
type: "button",
|
||||
className: "dim-contextHelpButton dim-contextFieldHelpButton",
|
||||
disabled,
|
||||
"aria-label": copy.senderNameHelpLabel,
|
||||
"aria-describedby": senderNameHelpId
|
||||
}, h2("span", { "aria-hidden": "true" }, "?")),
|
||||
h2("span", {
|
||||
id: senderNameHelpId,
|
||||
className: "dim-contextTooltip dim-contextFieldTooltip",
|
||||
role: "tooltip"
|
||||
}, "\u8BE5\u5B57\u6BB5\u4E0D\u662F\u6BCF\u4E2A\u6E20\u9053\u90FD\u80FD\u63D0\u4F9B\u3002\u5F53\u524D\u6D88\u606F\u6CA1\u6709\u53D1\u9001\u8005\u6635\u79F0\u65F6\uFF0C\u5373\u4F7F\u5DF2\u9009\u62E9\u8BE5\u5B57\u6BB5\uFF0C<dsh_im_source> \u4E2D\u4E5F\u4F1A\u7701\u7565 senderName\u3002")
|
||||
) : null,
|
||||
h2("label", { className: "dim-contextFieldKey", htmlFor: fieldId }, field)
|
||||
)
|
||||
);
|
||||
}))
|
||||
),
|
||||
h2(
|
||||
"div",
|
||||
{ className: "dim-contextGuidance dim-contextScopeBlock" },
|
||||
h2(
|
||||
"div",
|
||||
{ className: "dim-contextEditorHeader" },
|
||||
h2(
|
||||
"span",
|
||||
{ className: "dim-contextEditorTitle" },
|
||||
h2("label", { htmlFor: guidanceId }, copy.guidanceLabel),
|
||||
h2(
|
||||
"span",
|
||||
{ className: "dim-contextHelp" },
|
||||
h2("button", {
|
||||
type: "button",
|
||||
className: "dim-contextHelpButton",
|
||||
disabled,
|
||||
"aria-label": copy.guidanceHelpLabel,
|
||||
"aria-describedby": guidanceHelpId
|
||||
}, h2("span", { "aria-hidden": "true" }, "?")),
|
||||
h2(
|
||||
"span",
|
||||
{ id: guidanceHelpId, className: "dim-contextTooltip dim-contextGuidanceTooltip", role: "tooltip" },
|
||||
h2("strong", null, "\u4F7F\u7528\u8BF4\u660E"),
|
||||
h2("span", null, copy.guidanceUsage),
|
||||
h2("strong", null, "\u751F\u6548\u89C4\u5219"),
|
||||
h2("span", null, copy.guidanceBehavior),
|
||||
h2("strong", null, "\u9690\u79C1\u63D0\u793A"),
|
||||
h2("span", null, "\u53D1\u9001\u8005\u6807\u8BC6\u53EF\u80FD\u5305\u542B\u5E73\u53F0\u7528\u6237 ID \u6216\u7535\u8BDD\u53F7\u7801\u5F62\u5F0F\u7684\u6807\u8BC6\u3002\u5173\u95ED\u5F00\u5173\u4E0D\u4F1A\u5220\u9664\u5DF2\u7ECF\u5199\u5165\u4F1A\u8BDD\u5386\u53F2\u7684\u4FE1\u606F\u3002"),
|
||||
h2("strong", null, "\u4F7F\u7528\u793A\u4F8B"),
|
||||
h2("span", { className: "dim-contextTooltipExample" }, example)
|
||||
)
|
||||
)
|
||||
),
|
||||
h2(
|
||||
"div",
|
||||
{ className: "dim-contextTextActions" },
|
||||
h2("button", { type: "button", disabled, onClick: () => onChange("guidance", example) }, "\u586B\u5165\u793A\u4F8B"),
|
||||
h2("button", { type: "button", disabled, onClick: () => onChange("guidance", "") }, "\u6E05\u7A7A")
|
||||
)
|
||||
),
|
||||
h2("textarea", {
|
||||
id: guidanceId,
|
||||
value: scope.guidance,
|
||||
placeholder: example,
|
||||
rows: 4,
|
||||
disabled,
|
||||
"data-context-kind": kind,
|
||||
maxLength: CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH,
|
||||
"aria-describedby": guidanceHelpId,
|
||||
onChange: (event) => onChange("guidance", event.target.value)
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
function ContextEnhancementDialog({ config, groupSupported, disabled, onSave, onClose, returnFocusRef, id: id5 }) {
|
||||
const [draft, setDraft] = React7.useState(() => ({
|
||||
...normalizeContextEnhancementConfig(config),
|
||||
...!groupSupported ? { groupEnabled: false } : {}
|
||||
}));
|
||||
const [draft, setDraft] = React7.useState(() => {
|
||||
const normalized = normalizeContextEnhancementConfig(config);
|
||||
return {
|
||||
...normalized,
|
||||
...!groupSupported ? {
|
||||
group: { ...normalized.group, enabled: false }
|
||||
} : {}
|
||||
};
|
||||
});
|
||||
const [saving, setSaving] = React7.useState(false);
|
||||
const [error, setError] = React7.useState(null);
|
||||
const [activeScope, setActiveScope] = React7.useState("direct");
|
||||
const savingRef = React7.useRef(false);
|
||||
const dialogRef = React7.useRef(null);
|
||||
const mountedRef = React7.useRef(true);
|
||||
const groupTabRef = React7.useRef(null);
|
||||
const directTabRef = React7.useRef(null);
|
||||
const titleId = React7.useId();
|
||||
const descriptionId = React7.useId();
|
||||
const groupNoticeId = React7.useId();
|
||||
const guidanceId = React7.useId();
|
||||
const guidanceHelpId = React7.useId();
|
||||
const fieldIdPrefix = React7.useId();
|
||||
const fieldsHelpId = React7.useId();
|
||||
const senderNameHelpId = React7.useId();
|
||||
const guidanceExample = localizeText(CONTEXT_GUIDANCE_EXAMPLE);
|
||||
const scopeIdPrefix = React7.useId();
|
||||
const groupGuidanceExample = localizeText(CONTEXT_GROUP_GUIDANCE_EXAMPLE);
|
||||
const directGuidanceExample = localizeText(CONTEXT_DIRECT_GUIDANCE_EXAMPLE);
|
||||
const busy = disabled || saving;
|
||||
const scopeKinds = ["direct", "group"];
|
||||
const tabRefs = { group: groupTabRef, direct: directTabRef };
|
||||
React7.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
dialogRef.current?.focus?.();
|
||||
|
|
@ -2711,14 +2959,34 @@ function ContextEnhancementDialog({ config, groupSupported, disabled, onSave, on
|
|||
queueMicrotask(() => returnFocusRef.current?.focus?.());
|
||||
};
|
||||
}, [returnFocusRef]);
|
||||
const change = (key, value) => {
|
||||
const changeScope = (kind, key, value) => {
|
||||
if (busy || savingRef.current) return;
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[kind]: { ...current[kind], [key]: value }
|
||||
}));
|
||||
setError(null);
|
||||
};
|
||||
const cancel = () => {
|
||||
if (!savingRef.current) onClose();
|
||||
};
|
||||
const activateScope = (kind, focus = false) => {
|
||||
if (busy || !scopeKinds.includes(kind)) return;
|
||||
setActiveScope(kind);
|
||||
if (focus) tabRefs[kind].current?.focus?.();
|
||||
};
|
||||
const handleTabKeyDown = (event, kind) => {
|
||||
let next;
|
||||
if (event.key === "Home") next = scopeKinds[0];
|
||||
else if (event.key === "End") next = scopeKinds.at(-1);
|
||||
else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
const offset = event.key === "ArrowRight" ? 1 : -1;
|
||||
next = scopeKinds[(scopeKinds.indexOf(kind) + offset + scopeKinds.length) % scopeKinds.length];
|
||||
}
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
activateScope(next, true);
|
||||
};
|
||||
const save = async () => {
|
||||
if (busy || savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
|
|
@ -2812,166 +3080,45 @@ function ContextEnhancementDialog({ config, groupSupported, disabled, onSave, on
|
|||
onClick: cancel
|
||||
}, h2(ContextIcon, { kind: "close" }))
|
||||
),
|
||||
h2(
|
||||
"fieldset",
|
||||
{ className: "dim-contextSection", disabled: busy },
|
||||
h2("legend", null, "\u542F\u7528\u8303\u56F4"),
|
||||
h2(
|
||||
"div",
|
||||
{ className: "dim-contextSwitches" },
|
||||
h2(
|
||||
"label",
|
||||
{ className: "dim-contextSwitchRow" },
|
||||
h2(
|
||||
"span",
|
||||
{ className: "dim-contextSwitchLabel" },
|
||||
h2("span", null, "\u7FA4\u804A\u4E2D\u542F\u7528"),
|
||||
!groupSupported ? h2("span", {
|
||||
id: groupNoticeId,
|
||||
className: "dim-contextUnavailable"
|
||||
}, "\uFF08\u5F53\u524D\u6E20\u9053\u4E0D\u652F\u6301\u7FA4\u804A\uFF09") : null
|
||||
),
|
||||
h2("input", {
|
||||
type: "checkbox",
|
||||
role: "switch",
|
||||
className: "dim-contextSwitch",
|
||||
checked: groupSupported && draft.groupEnabled,
|
||||
disabled: busy || !groupSupported,
|
||||
"aria-describedby": !groupSupported ? groupNoticeId : void 0,
|
||||
onChange: (event) => {
|
||||
if (groupSupported) change("groupEnabled", event.target.checked);
|
||||
}
|
||||
})
|
||||
),
|
||||
h2(
|
||||
"label",
|
||||
{ className: "dim-contextSwitchRow" },
|
||||
h2("span", null, "\u79C1\u804A\u4E2D\u542F\u7528"),
|
||||
h2("input", {
|
||||
type: "checkbox",
|
||||
role: "switch",
|
||||
className: "dim-contextSwitch",
|
||||
checked: draft.directEnabled,
|
||||
disabled: busy,
|
||||
onChange: (event) => change("directEnabled", event.target.checked)
|
||||
})
|
||||
)
|
||||
)
|
||||
),
|
||||
h2(
|
||||
"fieldset",
|
||||
{ className: "dim-contextSection", disabled: busy },
|
||||
h2(
|
||||
"legend",
|
||||
{ className: "dim-contextLegend" },
|
||||
h2("span", null, "\u6765\u6E90\u5B57\u6BB5"),
|
||||
h2(
|
||||
"span",
|
||||
{ className: "dim-contextHelp dim-contextLegendHelp" },
|
||||
h2("button", {
|
||||
type: "button",
|
||||
className: "dim-contextHelpButton",
|
||||
disabled: busy,
|
||||
"aria-label": "\u67E5\u770B\u6765\u6E90\u5B57\u6BB5\u8BF4\u660E",
|
||||
"aria-describedby": fieldsHelpId
|
||||
}, h2("span", { "aria-hidden": "true" }, "?")),
|
||||
h2(
|
||||
"span",
|
||||
{ id: fieldsHelpId, className: "dim-contextTooltip dim-contextLegendTooltip", role: "tooltip" },
|
||||
"\u589E\u5F3A\u63D0\u793A\u8BCD\u4E2D\u8BF7\u4F7F\u7528\u5B57\u6BB5\u540D\uFF08\u5982 senderId\u3001conversationType\uFF09\u5F15\u7528\u8FD9\u4E9B\u4FE1\u606F\u3002\u53EA\u53D1\u9001\u52FE\u9009\u4E14\u5F53\u524D\u6D88\u606F\u4E2D\u53EF\u7528\u7684\u5B57\u6BB5\uFF0C\u4E0D\u4F1A\u989D\u5916\u67E5\u8BE2\u6216\u8865\u5168\u3002"
|
||||
)
|
||||
)
|
||||
),
|
||||
h2("div", { className: "dim-contextFields" }, CONTEXT_ENHANCEMENT_FIELDS.map((field) => {
|
||||
const fieldId = `${fieldIdPrefix}-${field}`;
|
||||
return h2(
|
||||
"div",
|
||||
{ key: field, className: "dim-contextField" },
|
||||
h2("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))
|
||||
}),
|
||||
h2(
|
||||
"span",
|
||||
{ className: "dim-contextFieldText" },
|
||||
h2("label", { className: "dim-contextFieldName", htmlFor: fieldId }, FIELD_LABELS[field]),
|
||||
field === "senderName" ? h2(
|
||||
"span",
|
||||
{ className: "dim-contextHelp dim-contextFieldHelp" },
|
||||
h2("button", {
|
||||
type: "button",
|
||||
className: "dim-contextHelpButton dim-contextFieldHelpButton",
|
||||
disabled: busy,
|
||||
"aria-label": "\u67E5\u770B\u53D1\u9001\u8005\u6635\u79F0\u5B57\u6BB5\u8BF4\u660E",
|
||||
"aria-describedby": senderNameHelpId
|
||||
}, h2("span", { "aria-hidden": "true" }, "?")),
|
||||
h2("span", {
|
||||
id: senderNameHelpId,
|
||||
className: "dim-contextTooltip dim-contextFieldTooltip",
|
||||
role: "tooltip"
|
||||
}, "\u8BE5\u5B57\u6BB5\u4E0D\u662F\u6BCF\u4E2A\u6E20\u9053\u90FD\u80FD\u63D0\u4F9B\u3002\u5F53\u524D\u6D88\u606F\u6CA1\u6709\u53D1\u9001\u8005\u6635\u79F0\u65F6\uFF0C\u5373\u4F7F\u5DF2\u9009\u62E9\u8BE5\u5B57\u6BB5\uFF0C<dsh_im_source> \u4E2D\u4E5F\u4F1A\u7701\u7565 senderName\u3002")
|
||||
) : null,
|
||||
h2("label", { className: "dim-contextFieldKey", htmlFor: fieldId }, field)
|
||||
)
|
||||
);
|
||||
}))
|
||||
),
|
||||
h2(
|
||||
"div",
|
||||
{ className: "dim-contextGuidance" },
|
||||
h2(
|
||||
"div",
|
||||
{ className: "dim-contextEditorHeader" },
|
||||
h2(
|
||||
"span",
|
||||
{ className: "dim-contextEditorTitle" },
|
||||
h2("label", { htmlFor: guidanceId }, "\u589E\u5F3A\u63D0\u793A\u8BCD"),
|
||||
h2(
|
||||
"span",
|
||||
{ className: "dim-contextHelp" },
|
||||
h2("button", {
|
||||
type: "button",
|
||||
className: "dim-contextHelpButton",
|
||||
disabled: busy,
|
||||
"aria-label": "\u67E5\u770B\u589E\u5F3A\u63D0\u793A\u8BCD\u4F7F\u7528\u8BF4\u660E",
|
||||
"aria-describedby": guidanceHelpId
|
||||
}, h2("span", { "aria-hidden": "true" }, "?")),
|
||||
h2(
|
||||
"span",
|
||||
{ id: guidanceHelpId, className: "dim-contextTooltip dim-contextGuidanceTooltip", role: "tooltip" },
|
||||
h2("strong", null, "\u4F7F\u7528\u8BF4\u660E"),
|
||||
h2("span", null, "\u7528\u4E8E\u544A\u8BC9\u6A21\u578B\u5982\u4F55\u4F7F\u7528 <dsh_im_source> \u4E2D\u5DF2\u9009\u62E9\u7684\u6765\u6E90\u5B57\u6BB5\u3002\u53EA\u586B\u5199\u6B63\u6587\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u6DFB\u52A0 <dsh_im_source_guidance> \u6210\u5BF9\u6807\u7B7E\u3002"),
|
||||
h2("strong", null, "\u751F\u6548\u89C4\u5219"),
|
||||
h2("span", null, "\u53EA\u9700\u586B\u5199\u6B63\u6587\uFF0C\u63D2\u4EF6\u81EA\u52A8\u6DFB\u52A0 <dsh_im_source_guidance> \u6210\u5BF9\u6807\u7B7E\u3002\u6E05\u7A7A\u5E76\u4FDD\u5B58\u540E\uFF0C\u4E0D\u518D\u9644\u52A0\u589E\u5F3A\u63D0\u793A\u8BCD\uFF0C\u5DF2\u9009\u6765\u6E90\u5B57\u6BB5\u4ECD\u6309\u5F00\u5173\u8BBE\u7F6E\u53D1\u9001\u3002"),
|
||||
h2("strong", null, "\u9690\u79C1\u63D0\u793A"),
|
||||
h2("span", null, "\u53D1\u9001\u8005\u6807\u8BC6\u53EF\u80FD\u5305\u542B\u5E73\u53F0\u7528\u6237 ID \u6216\u7535\u8BDD\u53F7\u7801\u5F62\u5F0F\u7684\u6807\u8BC6\u3002\u5173\u95ED\u5F00\u5173\u4E0D\u4F1A\u5220\u9664\u5DF2\u7ECF\u5199\u5165\u4F1A\u8BDD\u5386\u53F2\u7684\u4FE1\u606F\u3002"),
|
||||
h2("strong", null, "\u4F7F\u7528\u793A\u4F8B"),
|
||||
h2("span", { className: "dim-contextTooltipExample" }, guidanceExample)
|
||||
)
|
||||
)
|
||||
),
|
||||
h2(
|
||||
"div",
|
||||
{ className: "dim-contextTextActions" },
|
||||
h2("button", { type: "button", disabled: busy, onClick: () => change("guidance", guidanceExample) }, "\u586B\u5165\u793A\u4F8B"),
|
||||
h2("button", { type: "button", disabled: busy, onClick: () => change("guidance", "") }, "\u6E05\u7A7A")
|
||||
)
|
||||
),
|
||||
h2("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)
|
||||
{ className: "dim-contextTabs", role: "tablist", "aria-label": "\u4E0A\u4E0B\u6587\u589E\u5F3A\u8303\u56F4" },
|
||||
scopeKinds.map((kind) => {
|
||||
const selected = activeScope === kind;
|
||||
return h2("button", {
|
||||
key: kind,
|
||||
id: `${scopeIdPrefix}-${kind}-tab`,
|
||||
ref: tabRefs[kind],
|
||||
type: "button",
|
||||
role: "tab",
|
||||
className: "dim-contextTab",
|
||||
"data-context-kind": kind,
|
||||
"aria-selected": selected,
|
||||
"aria-controls": `${scopeIdPrefix}-${kind}-panel`,
|
||||
tabIndex: selected ? 0 : -1,
|
||||
disabled: busy,
|
||||
onClick: () => activateScope(kind),
|
||||
onKeyDown: (event) => handleTabKeyDown(event, kind)
|
||||
}, SCOPE_COPY[kind].title);
|
||||
})
|
||||
),
|
||||
scopeKinds.map((kind) => h2("div", {
|
||||
key: kind,
|
||||
id: `${scopeIdPrefix}-${kind}-panel`,
|
||||
className: "dim-contextTabPanel",
|
||||
role: "tabpanel",
|
||||
"data-context-kind": kind,
|
||||
"aria-labelledby": `${scopeIdPrefix}-${kind}-tab`,
|
||||
hidden: activeScope !== kind
|
||||
}, h2(ContextEnhancementScopeEditor, {
|
||||
kind,
|
||||
scope: draft[kind],
|
||||
example: kind === "group" ? groupGuidanceExample : directGuidanceExample,
|
||||
supported: kind === "group" ? groupSupported : true,
|
||||
busy,
|
||||
idPrefix: scopeIdPrefix,
|
||||
onChange: (key, value) => changeScope(kind, key, value)
|
||||
}))),
|
||||
error ? h2("p", { className: "dim-contextError", role: "alert" }, error) : null,
|
||||
h2(
|
||||
"footer",
|
||||
|
|
@ -2995,7 +3142,10 @@ function ContextEnhancementEditor({ config, groupSupported = true, disabled = fa
|
|||
const dialogId = React7.useId();
|
||||
const statusId = React7.useId();
|
||||
const saved = normalizeContextEnhancementConfig(config);
|
||||
const label = contextEnhancementLabel(groupSupported ? saved : { ...saved, groupEnabled: false });
|
||||
const label = contextEnhancementLabel(groupSupported ? saved : {
|
||||
...saved,
|
||||
group: { ...saved.group, enabled: false }
|
||||
});
|
||||
return h2(
|
||||
React7.Fragment,
|
||||
null,
|
||||
|
|
@ -12776,10 +12926,15 @@ var CSS11 = String.raw`
|
|||
.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-contextTabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px; margin-top: 14px; padding: 3px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 8px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.dim-contextTab { min-width: 0; min-height: 34px; padding: 5px 12px; border: 0; border-radius: 6px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-weight: 500; cursor: pointer; transition: color .15s ease, background .15s ease, box-shadow .15s ease; }
|
||||
.dim-contextTab:hover:not(:disabled):not([aria-selected="true"]) { color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
|
||||
.dim-contextTab[aria-selected="true"] { color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 1px 3px rgb(31 35 41 / 12%); }
|
||||
.dim-contextTabPanel[hidden] { display: none; }
|
||||
.dim-contextSection { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.dim-contextScope { margin-top: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; background: var(--dsw-alias-bg-layer-1, #fff); }
|
||||
.dim-contextScopeBlock { margin-top: 12px; }
|
||||
.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; }
|
||||
|
|
@ -12822,7 +12977,7 @@ var CSS11 = String.raw`
|
|||
.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-contextEntry, .dim-contextTab, .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; }
|
||||
}
|
||||
|
|
|
|||
525
lib/index.js
525
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -2,9 +2,10 @@ import * as React from 'react';
|
|||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
CONTEXT_DIRECT_GUIDANCE_EXAMPLE,
|
||||
CONTEXT_ENHANCEMENT_FIELDS,
|
||||
CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH,
|
||||
CONTEXT_GUIDANCE_EXAMPLE,
|
||||
CONTEXT_GROUP_GUIDANCE_EXAMPLE,
|
||||
normalizeContextEnhancementConfig,
|
||||
validateContextEnhancementConfig,
|
||||
} from '../../src/channels/shared/context-enhancement.mjs';
|
||||
|
|
@ -18,11 +19,34 @@ const FIELD_LABELS = Object.freeze({
|
|||
botId: '机器人标识',
|
||||
});
|
||||
|
||||
const SCOPE_COPY = Object.freeze({
|
||||
group: Object.freeze({
|
||||
title: '群聊',
|
||||
enable: '启用',
|
||||
fieldsHelpLabel: '查看群聊来源字段说明',
|
||||
senderNameHelpLabel: '查看群聊发送者昵称字段说明',
|
||||
guidanceLabel: '增强提示词',
|
||||
guidanceHelpLabel: '查看群聊增强提示词使用说明',
|
||||
guidanceUsage: '用于告诉模型如何使用当前群聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
|
||||
guidanceBehavior: '仅在群聊开关开启时使用。清空并保存后不再附加群聊增强提示词;所选来源字段仍按当前场景设置发送。',
|
||||
}),
|
||||
direct: Object.freeze({
|
||||
title: '私聊',
|
||||
enable: '启用',
|
||||
fieldsHelpLabel: '查看私聊来源字段说明',
|
||||
senderNameHelpLabel: '查看私聊发送者昵称字段说明',
|
||||
guidanceLabel: '增强提示词',
|
||||
guidanceHelpLabel: '查看私聊增强提示词使用说明',
|
||||
guidanceUsage: '用于告诉模型如何使用当前私聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
|
||||
guidanceBehavior: '仅在私聊开关开启时使用。清空并保存后不再附加私聊增强提示词;所选来源字段仍按当前场景设置发送。',
|
||||
}),
|
||||
});
|
||||
|
||||
export function contextEnhancementLabel(config) {
|
||||
const { groupEnabled, directEnabled } = normalizeContextEnhancementConfig(config);
|
||||
if (groupEnabled && directEnabled) return '群聊和私聊';
|
||||
if (groupEnabled) return '仅群聊';
|
||||
if (directEnabled) return '仅私聊';
|
||||
const { group, direct } = normalizeContextEnhancementConfig(config);
|
||||
if (group.enabled && direct.enabled) return '群聊和私聊';
|
||||
if (group.enabled) return '仅群聊';
|
||||
if (direct.enabled) return '仅私聊';
|
||||
return '未开启';
|
||||
}
|
||||
|
||||
|
|
@ -37,27 +61,132 @@ function ContextIcon({ kind = 'sliders' }) {
|
|||
}, h('path', { d: path }));
|
||||
}
|
||||
|
||||
function ContextEnhancementScopeEditor({
|
||||
kind,
|
||||
scope,
|
||||
example,
|
||||
supported,
|
||||
busy,
|
||||
idPrefix,
|
||||
onChange,
|
||||
}) {
|
||||
const copy = SCOPE_COPY[kind];
|
||||
const unavailableId = `${idPrefix}-${kind}-unavailable`;
|
||||
const fieldsHelpId = `${idPrefix}-${kind}-fields-help`;
|
||||
const senderNameHelpId = `${idPrefix}-${kind}-sender-name-help`;
|
||||
const guidanceId = `${idPrefix}-${kind}-guidance`;
|
||||
const guidanceHelpId = `${idPrefix}-${kind}-guidance-help`;
|
||||
const disabled = busy || !supported;
|
||||
|
||||
return h('fieldset', {
|
||||
className: 'dim-contextSection dim-contextScope',
|
||||
'data-context-kind': kind,
|
||||
'aria-label': copy.title,
|
||||
disabled,
|
||||
},
|
||||
h('label', { className: 'dim-contextSwitchRow' },
|
||||
h('span', { className: 'dim-contextSwitchLabel' },
|
||||
h('span', null, copy.enable),
|
||||
!supported ? h('span', {
|
||||
id: unavailableId, className: 'dim-contextUnavailable',
|
||||
}, '(当前渠道不支持群聊)') : null),
|
||||
h('input', {
|
||||
type: 'checkbox', role: 'switch', className: 'dim-contextSwitch',
|
||||
checked: supported && scope.enabled,
|
||||
disabled,
|
||||
'aria-describedby': !supported ? unavailableId : undefined,
|
||||
onChange: (event) => { if (supported) onChange('enabled', event.target.checked); },
|
||||
})),
|
||||
h('div', { className: 'dim-contextScopeBlock' },
|
||||
h('div', { className: 'dim-contextLegend' },
|
||||
h('span', null, '来源字段'),
|
||||
h('span', { className: 'dim-contextHelp dim-contextLegendHelp' },
|
||||
h('button', {
|
||||
type: 'button', className: 'dim-contextHelpButton', disabled,
|
||||
'aria-label': copy.fieldsHelpLabel, '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 = `${idPrefix}-${kind}-field-${field}`;
|
||||
return h('div', { key: field, className: 'dim-contextField' },
|
||||
h('input', {
|
||||
id: fieldId, type: 'checkbox', name: `${kind}-${field}`,
|
||||
checked: scope.fields.includes(field), disabled,
|
||||
onChange: (event) => onChange('fields', event.target.checked
|
||||
? [...scope.fields, field] : scope.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,
|
||||
'aria-label': copy.senderNameHelpLabel, '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 dim-contextScopeBlock' },
|
||||
h('div', { className: 'dim-contextEditorHeader' },
|
||||
h('span', { className: 'dim-contextEditorTitle' },
|
||||
h('label', { htmlFor: guidanceId }, copy.guidanceLabel),
|
||||
h('span', { className: 'dim-contextHelp' },
|
||||
h('button', {
|
||||
type: 'button', className: 'dim-contextHelpButton', disabled,
|
||||
'aria-label': copy.guidanceHelpLabel, '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, copy.guidanceUsage),
|
||||
h('strong', null, '生效规则'),
|
||||
h('span', null, copy.guidanceBehavior),
|
||||
h('strong', null, '隐私提示'),
|
||||
h('span', null, '发送者标识可能包含平台用户 ID 或电话号码形式的标识。关闭开关不会删除已经写入会话历史的信息。'),
|
||||
h('strong', null, '使用示例'),
|
||||
h('span', { className: 'dim-contextTooltipExample' }, example)))),
|
||||
h('div', { className: 'dim-contextTextActions' },
|
||||
h('button', { type: 'button', disabled, onClick: () => onChange('guidance', example) }, '填入示例'),
|
||||
h('button', { type: 'button', disabled, onClick: () => onChange('guidance', '') }, '清空'))),
|
||||
h('textarea', {
|
||||
id: guidanceId, value: scope.guidance, placeholder: example, rows: 4, disabled,
|
||||
'data-context-kind': kind,
|
||||
maxLength: CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH,
|
||||
'aria-describedby': guidanceHelpId,
|
||||
onChange: (event) => onChange('guidance', event.target.value),
|
||||
})));
|
||||
}
|
||||
|
||||
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 [draft, setDraft] = React.useState(() => {
|
||||
const normalized = normalizeContextEnhancementConfig(config);
|
||||
return {
|
||||
...normalized,
|
||||
...(!groupSupported ? {
|
||||
group: { ...normalized.group, enabled: false },
|
||||
} : {}),
|
||||
};
|
||||
});
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState(null);
|
||||
const [activeScope, setActiveScope] = React.useState('direct');
|
||||
const savingRef = React.useRef(false);
|
||||
const dialogRef = React.useRef(null);
|
||||
const mountedRef = React.useRef(true);
|
||||
const groupTabRef = React.useRef(null);
|
||||
const directTabRef = React.useRef(null);
|
||||
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 scopeIdPrefix = React.useId();
|
||||
const groupGuidanceExample = localizeText(CONTEXT_GROUP_GUIDANCE_EXAMPLE);
|
||||
const directGuidanceExample = localizeText(CONTEXT_DIRECT_GUIDANCE_EXAMPLE);
|
||||
const busy = disabled || saving;
|
||||
const scopeKinds = ['direct', 'group'];
|
||||
const tabRefs = { group: groupTabRef, direct: directTabRef };
|
||||
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
|
@ -73,9 +202,12 @@ function ContextEnhancementDialog({ config, groupSupported, disabled, onSave, on
|
|||
};
|
||||
}, [returnFocusRef]);
|
||||
|
||||
const change = (key, value) => {
|
||||
const changeScope = (kind, key, value) => {
|
||||
if (busy || savingRef.current) return;
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[kind]: { ...current[kind], [key]: value },
|
||||
}));
|
||||
setError(null);
|
||||
};
|
||||
|
||||
|
|
@ -83,6 +215,25 @@ function ContextEnhancementDialog({ config, groupSupported, disabled, onSave, on
|
|||
if (!savingRef.current) onClose();
|
||||
};
|
||||
|
||||
const activateScope = (kind, focus = false) => {
|
||||
if (busy || !scopeKinds.includes(kind)) return;
|
||||
setActiveScope(kind);
|
||||
if (focus) tabRefs[kind].current?.focus?.();
|
||||
};
|
||||
|
||||
const handleTabKeyDown = (event, kind) => {
|
||||
let next;
|
||||
if (event.key === 'Home') next = scopeKinds[0];
|
||||
else if (event.key === 'End') next = scopeKinds.at(-1);
|
||||
else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
const offset = event.key === 'ArrowRight' ? 1 : -1;
|
||||
next = scopeKinds[(scopeKinds.indexOf(kind) + offset + scopeKinds.length) % scopeKinds.length];
|
||||
}
|
||||
if (!next) return;
|
||||
event.preventDefault();
|
||||
activateScope(next, true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (busy || savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
|
|
@ -155,90 +306,42 @@ function ContextEnhancementDialog({ config, groupSupported, disabled, onSave, on
|
|||
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('div', { className: 'dim-contextTabs', role: 'tablist', 'aria-label': '上下文增强范围' },
|
||||
scopeKinds.map((kind) => {
|
||||
const selected = activeScope === kind;
|
||||
return h('button', {
|
||||
key: kind,
|
||||
id: `${scopeIdPrefix}-${kind}-tab`,
|
||||
ref: tabRefs[kind],
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
className: 'dim-contextTab',
|
||||
'data-context-kind': kind,
|
||||
'aria-selected': selected,
|
||||
'aria-controls': `${scopeIdPrefix}-${kind}-panel`,
|
||||
tabIndex: selected ? 0 : -1,
|
||||
disabled: busy,
|
||||
onClick: () => activateScope(kind),
|
||||
onKeyDown: (event) => handleTabKeyDown(event, kind),
|
||||
}, SCOPE_COPY[kind].title);
|
||||
})),
|
||||
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),
|
||||
scopeKinds.map((kind) => h('div', {
|
||||
key: kind,
|
||||
id: `${scopeIdPrefix}-${kind}-panel`,
|
||||
className: 'dim-contextTabPanel',
|
||||
role: 'tabpanel',
|
||||
'data-context-kind': kind,
|
||||
'aria-labelledby': `${scopeIdPrefix}-${kind}-tab`,
|
||||
hidden: activeScope !== kind,
|
||||
}, h(ContextEnhancementScopeEditor, {
|
||||
kind,
|
||||
scope: draft[kind],
|
||||
example: kind === 'group' ? groupGuidanceExample : directGuidanceExample,
|
||||
supported: kind === 'group' ? groupSupported : true,
|
||||
busy,
|
||||
idPrefix: scopeIdPrefix,
|
||||
onChange: (key, value) => changeScope(kind, key, value),
|
||||
}))),
|
||||
),
|
||||
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 }, '取消'),
|
||||
|
|
@ -256,7 +359,9 @@ export function ContextEnhancementEditor({ config, groupSupported = true, disabl
|
|||
const dialogId = React.useId();
|
||||
const statusId = React.useId();
|
||||
const saved = normalizeContextEnhancementConfig(config);
|
||||
const label = contextEnhancementLabel(groupSupported ? saved : { ...saved, groupEnabled: false });
|
||||
const label = contextEnhancementLabel(groupSupported ? saved : {
|
||||
...saved, group: { ...saved.group, enabled: false },
|
||||
});
|
||||
|
||||
return h(React.Fragment, null,
|
||||
h('button', {
|
||||
|
|
|
|||
|
|
@ -216,31 +216,44 @@ const EN = Object.freeze({
|
|||
'群聊和私聊': 'Groups and direct',
|
||||
'关闭弹窗': 'Close dialog',
|
||||
'查看上下文增强说明': 'View context enhancement details',
|
||||
'上下文增强范围': 'Context enhancement scope',
|
||||
'启用范围': 'Enable in',
|
||||
'群聊中启用': 'Enable in group chats',
|
||||
'私聊中启用': 'Enable in direct chats',
|
||||
'启用': 'Enabled',
|
||||
'(当前渠道不支持群聊)': '(This channel does not support group chats)',
|
||||
'来源字段': 'Source fields',
|
||||
'查看来源字段说明': 'View source field details',
|
||||
'查看群聊来源字段说明': 'View group source field details',
|
||||
'查看私聊来源字段说明': 'View direct source field details',
|
||||
'渠道': 'Channel',
|
||||
'会话类型': 'Conversation type',
|
||||
'发送者标识': 'Sender ID',
|
||||
'发送者昵称': 'Sender name',
|
||||
'查看发送者昵称字段说明': 'View sender name field details',
|
||||
'查看群聊发送者昵称字段说明': 'View group sender name field details',
|
||||
'查看私聊发送者昵称字段说明': 'View direct 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',
|
||||
'查看群聊增强提示词使用说明': 'View group guidance instructions',
|
||||
'查看私聊增强提示词使用说明': 'View direct 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.',
|
||||
'仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。\n当前消息来自群聊,请使用严肃、克制、简洁的表达方式。': 'Understand the source only from fields actually present in <dsh_im_source> for the current message; do not guess or fill in missing fields.\nThis message is from a group chat. Use a serious, restrained, and concise style.',
|
||||
'仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。\n当前消息来自私聊,可以使用更轻松、幽默、详细的表达方式。': 'Understand the source only from fields actually present in <dsh_im_source> for the current message; do not guess or fill in missing fields.\nThis message is from a direct chat. You may use a lighter, more humorous, and detailed style.',
|
||||
'填入示例': '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.',
|
||||
'增强提示词中请使用字段名(如 senderId、conversationType)引用这些信息。只发送当前会话中勾选且可用的字段,不会额外查询或补全。': 'Reference these values by field name (such as senderId or conversationType) in the guidance. Only fields selected and available in the current conversation are sent; no extra lookups are made.',
|
||||
'用于告诉模型如何使用当前群聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。': 'Tell the model how to use <dsh_im_source> fields for the current group message. Enter only the body; the plugin adds paired <dsh_im_source_guidance> tags.',
|
||||
'用于告诉模型如何使用当前私聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。': 'Tell the model how to use <dsh_im_source> fields for the current direct message. Enter only the body; the plugin adds paired <dsh_im_source_guidance> tags.',
|
||||
'仅在群聊开关开启时使用。清空并保存后不再附加群聊增强提示词;所选来源字段仍按当前场景设置发送。': 'Used only when group enhancement is enabled. Clear and save to omit group guidance; selected source fields still follow the group settings.',
|
||||
'仅在私聊开关开启时使用。清空并保存后不再附加私聊增强提示词;所选来源字段仍按当前场景设置发送。': 'Used only when direct enhancement is enabled. Clear and save to omit direct guidance; selected source fields still follow the direct settings.',
|
||||
'只需填写正文,插件自动添加 <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.',
|
||||
|
|
|
|||
|
|
@ -228,10 +228,15 @@ const CSS = String.raw`
|
|||
.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-contextTabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px; margin-top: 14px; padding: 3px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 8px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
|
||||
.dim-contextTab { min-width: 0; min-height: 34px; padding: 5px 12px; border: 0; border-radius: 6px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-weight: 500; cursor: pointer; transition: color .15s ease, background .15s ease, box-shadow .15s ease; }
|
||||
.dim-contextTab:hover:not(:disabled):not([aria-selected="true"]) { color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
|
||||
.dim-contextTab[aria-selected="true"] { color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 1px 3px rgb(31 35 41 / 12%); }
|
||||
.dim-contextTabPanel[hidden] { display: none; }
|
||||
.dim-contextSection { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.dim-contextScope { margin-top: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; background: var(--dsw-alias-bg-layer-1, #fff); }
|
||||
.dim-contextScopeBlock { margin-top: 12px; }
|
||||
.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; }
|
||||
|
|
@ -274,7 +279,7 @@ const CSS = String.raw`
|
|||
.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-contextEntry, .dim-contextTab, .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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,12 +142,13 @@ if (client.includes('settings.plugins.tab') || clientSources.includes('settings.
|
|||
throw new Error('client source or bundle still contains the legacy Plugins-tab settings entry');
|
||||
}
|
||||
// Connections still have no channel-enable toggle. Only the shared context
|
||||
// editor owns checkable inputs: two scope switches and one mapped field input.
|
||||
// editor owns checkable inputs. Its reusable scope component contains one
|
||||
// switch template and one mapped field-input template; it renders both twice.
|
||||
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) {
|
||||
|| (client.match(/role:\s*["']switch["']/g) ?? []).length !== 1
|
||||
|| (client.match(/type:\s*["']checkbox["']/g) ?? []).length !== 2) {
|
||||
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>']) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ export const CONTEXT_ENHANCEMENT_FIELDS = Object.freeze([
|
|||
]);
|
||||
|
||||
export const CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH = 8_000;
|
||||
export const CONTEXT_GROUP_GUIDANCE_EXAMPLE = `仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
|
||||
当前消息来自群聊,请使用严肃、克制、简洁的表达方式。`;
|
||||
export const CONTEXT_DIRECT_GUIDANCE_EXAMPLE = `仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
|
||||
当前消息来自私聊,可以使用更轻松、幽默、详细的表达方式。`;
|
||||
|
||||
// Kept for integrations that imported the original combined example.
|
||||
export const CONTEXT_GUIDANCE_EXAMPLE = `仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
|
||||
conversationType是群聊时回复严肃一点,conversationType是私聊时回复一定要幽默搞笑,像周星驰的电影一样搞笑`;
|
||||
|
||||
|
|
@ -11,13 +17,21 @@ conversationType是群聊时回复严肃一点,conversationType是私聊时回
|
|||
export const DEFAULT_CONTEXT_GUIDANCE = CONTEXT_GUIDANCE_EXAMPLE;
|
||||
|
||||
export const DEFAULT_CONTEXT_ENHANCEMENT_CONFIG = Object.freeze({
|
||||
groupEnabled: false,
|
||||
directEnabled: false,
|
||||
group: Object.freeze({
|
||||
enabled: false,
|
||||
fields: Object.freeze(['senderId']),
|
||||
guidance: '',
|
||||
}),
|
||||
direct: Object.freeze({
|
||||
enabled: false,
|
||||
fields: Object.freeze(['senderId']),
|
||||
guidance: '',
|
||||
}),
|
||||
});
|
||||
|
||||
const CONFIG_KEYS = ['groupEnabled', 'directEnabled', 'fields', 'guidance'];
|
||||
const CONFIG_KEYS = ['group', 'direct'];
|
||||
const SCOPE_KEYS = ['enabled', 'fields', 'guidance'];
|
||||
const LEGACY_CONFIG_KEYS = ['groupEnabled', 'directEnabled', 'fields', 'guidance'];
|
||||
const CHANNELS = new Set([
|
||||
'wecom', 'weixin', 'feishu', 'dingtalk', 'qq',
|
||||
'slack', 'telegram', 'discord', 'whatsapp',
|
||||
|
|
@ -31,16 +45,19 @@ function invalidConfig(message) {
|
|||
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))) {
|
||||
function hasExactKeys(input, keys) {
|
||||
return input && typeof input === 'object' && !Array.isArray(input)
|
||||
&& [Object.prototype, null].includes(Object.getPrototypeOf(input))
|
||||
&& Reflect.ownKeys(input).length === keys.length
|
||||
&& keys.every((key) => Object.hasOwn(input, key));
|
||||
}
|
||||
|
||||
function validateContextEnhancementScope(input) {
|
||||
if (!hasExactKeys(input, SCOPE_KEYS)) {
|
||||
throw invalidConfig('请提交完整的上下文增强设置。');
|
||||
}
|
||||
const { groupEnabled, directEnabled, fields, guidance } = input;
|
||||
if (typeof groupEnabled !== 'boolean' || typeof directEnabled !== 'boolean') {
|
||||
const { enabled, fields, guidance } = input;
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw invalidConfig('群聊和私聊开关必须是布尔值。');
|
||||
}
|
||||
if (!Array.isArray(fields) || ![...fields].every((field) => CONTEXT_ENHANCEMENT_FIELDS.includes(field))) {
|
||||
|
|
@ -50,32 +67,68 @@ export function validateContextEnhancementConfig(input) {
|
|||
throw invalidConfig(`增强提示词不得超过 ${CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH} 个字符。`);
|
||||
}
|
||||
return Object.freeze({
|
||||
groupEnabled,
|
||||
directEnabled,
|
||||
enabled,
|
||||
fields: Object.freeze(CONTEXT_ENHANCEMENT_FIELDS.filter((field) => fields.includes(field))),
|
||||
guidance: guidance.trim() ? guidance : '',
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate the complete atomic save, preserving explicit empty selections/text. */
|
||||
export function validateContextEnhancementConfig(input) {
|
||||
if (!hasExactKeys(input, CONFIG_KEYS)) {
|
||||
throw invalidConfig('请提交完整的上下文增强设置。');
|
||||
}
|
||||
return Object.freeze({
|
||||
group: validateContextEnhancementScope(input.group),
|
||||
direct: validateContextEnhancementScope(input.direct),
|
||||
});
|
||||
}
|
||||
|
||||
function migrateLegacyContextEnhancementConfig(input) {
|
||||
if (!hasExactKeys(input, LEGACY_CONFIG_KEYS)) {
|
||||
throw invalidConfig('请提交完整的上下文增强设置。');
|
||||
}
|
||||
return validateContextEnhancementConfig({
|
||||
group: {
|
||||
enabled: input.groupEnabled,
|
||||
fields: input.fields,
|
||||
guidance: input.guidance,
|
||||
},
|
||||
direct: {
|
||||
enabled: input.directEnabled,
|
||||
fields: input.fields,
|
||||
guidance: input.guidance,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Missing or damaged enhancement settings must never break an existing bot. */
|
||||
export function normalizeContextEnhancementConfig(input) {
|
||||
try {
|
||||
return validateContextEnhancementConfig(input);
|
||||
} catch {
|
||||
try {
|
||||
return migrateLegacyContextEnhancementConfig(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 legacyEnabledKey = conversationType === 'group' ? 'groupEnabled' : 'directEnabled';
|
||||
const enabled = Object.hasOwn(settings ?? {}, conversationType)
|
||||
? settings?.[conversationType]?.enabled
|
||||
: settings?.[legacyEnabledKey];
|
||||
if (enabled !== true) return null;
|
||||
const config = normalizeContextEnhancementConfig(settings);
|
||||
if (config[enabledKey] !== true) return null;
|
||||
return Object.freeze({ config, botId: provider.botId, conversationType });
|
||||
const scope = config[conversationType];
|
||||
if (scope.enabled !== true) return null;
|
||||
return Object.freeze({ config: scope, botId: provider.botId, conversationType });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,7 +260,8 @@ for (const scenario of [
|
|||
const order = [];
|
||||
const asked = [];
|
||||
const configFor = ([directEnabled, groupEnabled], guidance) => ({
|
||||
directEnabled, groupEnabled, fields: ['channel', 'botId'], guidance,
|
||||
group: { enabled: groupEnabled, fields: ['channel', 'botId'], guidance },
|
||||
direct: { enabled: directEnabled, fields: ['channel', 'botId'], guidance },
|
||||
});
|
||||
let config = configFor(scenario.before, 'before callback returned');
|
||||
let callback;
|
||||
|
|
|
|||
|
|
@ -1446,10 +1446,12 @@ test('Discord captures context settings before asynchronous Thread routing and u
|
|||
let socket;
|
||||
let reads = 0;
|
||||
let config = {
|
||||
groupEnabled: true,
|
||||
directEnabled: false,
|
||||
group: {
|
||||
enabled: true,
|
||||
fields: ['channel', 'conversationType', 'senderId', 'senderName', 'botId'],
|
||||
guidance: 'accepted before routing',
|
||||
},
|
||||
direct: { enabled: false, fields: [], guidance: 'direct must not leak' },
|
||||
};
|
||||
const runtime = new DiscordRuntime({
|
||||
config: { botId: 'discord_internal', platformId: botId, name: 'Harness Discord' },
|
||||
|
|
@ -1502,7 +1504,7 @@ test('Discord captures context settings before asynchronous Thread routing and u
|
|||
member: { nick: 'Group Nick' }, mentions: [{ id: botId }], content: `<@${botId}> first`,
|
||||
} }) });
|
||||
await routingStarted.promise;
|
||||
config = { ...config, groupEnabled: false };
|
||||
config = { ...config, group: { ...config.group, enabled: false } };
|
||||
releaseRouting.resolve();
|
||||
await eventually(() => runtime.status.messagesReplied === 1);
|
||||
assert.match(prompts[0], /accepted before routing/);
|
||||
|
|
|
|||
|
|
@ -25,7 +25,20 @@ 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 };
|
||||
const {
|
||||
groupEnabled = true,
|
||||
directEnabled = true,
|
||||
fields = [...CONTEXT_ENHANCEMENT_FIELDS],
|
||||
guidance = '',
|
||||
group = {},
|
||||
direct = {},
|
||||
...extra
|
||||
} = overrides;
|
||||
return {
|
||||
group: { enabled: groupEnabled, fields: [...fields], guidance, ...group },
|
||||
direct: { enabled: directEnabled, fields: [...fields], guidance, ...direct },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function provider(channel, config) {
|
||||
|
|
@ -304,6 +317,24 @@ for (const channel of CHANNELS) {
|
|||
});
|
||||
}
|
||||
|
||||
if (channel !== 'weixin') {
|
||||
test(`${channel}: group and direct fields and guidance stay isolated`, async () => {
|
||||
const config = settings({
|
||||
group: { enabled: true, fields: ['channel'], guidance: 'GROUP-ONLY-TOKEN' },
|
||||
direct: { enabled: true, fields: ['botId'], guidance: 'DIRECT-ONLY-TOKEN' },
|
||||
});
|
||||
const current = fixture(channel, { contextEnhancement: provider(channel, config) });
|
||||
await current.bridge.accept(current.event(1, 'direct message', { kind: 'direct' }));
|
||||
await current.bridge.accept(current.event(2, 'group message', { kind: 'group' }));
|
||||
assert.deepEqual(sourceOf(current.prompts[0]), { botId: `${channel}_internal` });
|
||||
assert.match(current.prompts[0], /DIRECT-ONLY-TOKEN/);
|
||||
assert.doesNotMatch(current.prompts[0], /GROUP-ONLY-TOKEN|"channel"/);
|
||||
assert.deepEqual(sourceOf(current.prompts[1]), { channel });
|
||||
assert.match(current.prompts[1], /GROUP-ONLY-TOKEN/);
|
||||
assert.doesNotMatch(current.prompts[1], /DIRECT-ONLY-TOKEN|"botId"/);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -338,7 +369,7 @@ for (const channel of CHANNELS) {
|
|||
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.direct.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 });
|
||||
|
|
@ -409,8 +440,8 @@ for (const channel of CHANNELS) {
|
|||
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');
|
||||
if (config.direct.fields.length) assert.deepEqual(sourceOf(current.prompts[0]), { channel });
|
||||
else if (config.direct.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');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,18 +77,24 @@ for (const channel of ['wecom', 'weixin', 'feishu', 'dingtalk', 'qq', 'slack', '
|
|||
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 selected = {
|
||||
group: { enabled: false, fields: ['botId'], guidance: 'stored group guidance' },
|
||||
direct: { enabled: 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.deepEqual(beforeDisable.config, selected.direct);
|
||||
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 });
|
||||
await production.controller.updateContextEnhancement(botIds[0], {
|
||||
...selected,
|
||||
direct: { ...selected.direct, enabled: false },
|
||||
});
|
||||
assert.equal(captureContextEnhancement(provider, 'direct'), null);
|
||||
assert.equal(beforeDisable.config.directEnabled, true, 'an already received message keeps its snapshot');
|
||||
assert.equal(beforeDisable.config.enabled, true, 'an already received message keeps its snapshot');
|
||||
assert.equal(workspaces.generationFor(botIds[0]), generation);
|
||||
assert.equal(runtimes.length, 2);
|
||||
assert.equal(stateLoads, 2);
|
||||
|
|
|
|||
|
|
@ -1556,7 +1556,8 @@ for (const scenario of [
|
|||
let settingsReads = 0;
|
||||
let nextMessageId = 500;
|
||||
const configFor = ([directEnabled, groupEnabled], guidance) => ({
|
||||
directEnabled, groupEnabled, fields: ['channel', 'botId'], guidance,
|
||||
group: { enabled: groupEnabled, fields: ['channel', 'botId'], guidance },
|
||||
direct: { enabled: directEnabled, fields: ['channel', 'botId'], guidance },
|
||||
});
|
||||
let config = configFor(scenario.before, 'before cursor write');
|
||||
const update = (id, kind) => ({
|
||||
|
|
|
|||
|
|
@ -45,10 +45,22 @@ const CHANNELS = [
|
|||
['whatsapp', createWhatsappRpcHandler, WHATSAPP_ENDPOINTS],
|
||||
];
|
||||
|
||||
const enabled = (overrides = {}) => ({
|
||||
groupEnabled: true, directEnabled: true, fields: ['channel', 'botId'], guidance: 'saved guidance',
|
||||
...overrides,
|
||||
});
|
||||
const enabled = (overrides = {}) => {
|
||||
const {
|
||||
groupEnabled = true,
|
||||
directEnabled = true,
|
||||
fields = ['channel', 'botId'],
|
||||
guidance = 'saved guidance',
|
||||
group = {},
|
||||
direct = {},
|
||||
...extra
|
||||
} = overrides;
|
||||
return {
|
||||
group: { enabled: groupEnabled, fields, guidance, ...group },
|
||||
direct: { enabled: directEnabled, fields, guidance, ...direct },
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
async function fixture(t) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-context-settings-'));
|
||||
|
|
@ -105,6 +117,37 @@ test('old workspace files remain untouched and default enhancement off', async (
|
|||
assert.equal(await readFile(path, 'utf8'), original, 'reading/decorating old config must not migrate/write it');
|
||||
});
|
||||
|
||||
test('legacy enhancement settings migrate in memory and persist lazily on the next successful write', async (t) => {
|
||||
const { directory, path } = await fixture(t);
|
||||
const legacy = {
|
||||
groupEnabled: true,
|
||||
directEnabled: false,
|
||||
fields: ['botId', 'channel', 'botId'],
|
||||
guidance: 'legacy guidance',
|
||||
};
|
||||
const original = JSON.stringify({
|
||||
version: 1,
|
||||
workspaces: { legacy_bot: directory },
|
||||
contextEnhancement: { legacy_bot: legacy },
|
||||
});
|
||||
await writeFile(path, original);
|
||||
const store = await new BotWorkspaceStore(path).load();
|
||||
assert.deepEqual(store.contextEnhancementFor('legacy_bot'), enabled({
|
||||
directEnabled: false,
|
||||
fields: ['channel', 'botId'],
|
||||
guidance: 'legacy guidance',
|
||||
}));
|
||||
assert.equal(await readFile(path, 'utf8'), original, 'loading legacy settings must not write');
|
||||
await store.setAgentPreset('legacy_bot', 'preset-one');
|
||||
const migrated = JSON.parse(await readFile(path, 'utf8')).contextEnhancement.legacy_bot;
|
||||
assert.deepEqual(migrated, enabled({
|
||||
directEnabled: false,
|
||||
fields: ['channel', 'botId'],
|
||||
guidance: 'legacy guidance',
|
||||
}));
|
||||
assert.equal(Object.hasOwn(migrated, 'guidance'), false);
|
||||
});
|
||||
|
||||
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' });
|
||||
|
|
@ -113,7 +156,7 @@ test('complete per-bot settings persist all switch combinations and explicit emp
|
|||
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 selected = enabled({ groupEnabled, directEnabled, fields: [], guidance: '' });
|
||||
const saved = await store.setContextEnhancement('bot_one', selected);
|
||||
assert.deepEqual(saved, selected);
|
||||
assert.equal(store.contextEnhancementFor('bot_one'), saved);
|
||||
|
|
@ -185,7 +228,8 @@ test('pending and failed writes never publish an uncommitted context snapshot',
|
|||
assert.match(text, /saved guidance/);
|
||||
assert.match(text, /"botId":"bot_one"/);
|
||||
assert.equal(Object.isFrozen(first), true);
|
||||
assert.equal(Object.isFrozen(first.fields), true);
|
||||
assert.equal(Object.isFrozen(first.group.fields), true);
|
||||
assert.equal(Object.isFrozen(first.direct.fields), true);
|
||||
});
|
||||
|
||||
test('invalid saves reject atomically and deleted or rebound bot incarnations cannot receive stale updates', async (t) => {
|
||||
|
|
@ -259,7 +303,7 @@ test('failed cleanup retires context settings and startup reconciliation heals s
|
|||
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);
|
||||
assert.equal(reloaded.contextEnhancementFor('bot_one').group.enabled, 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);
|
||||
|
|
@ -515,7 +559,9 @@ for (const [channel, createHandler, endpoints] of CHANNELS) {
|
|||
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, '');
|
||||
assert.deepEqual(cleared.value.bots[0].contextEnhancement.group.fields, []);
|
||||
assert.deepEqual(cleared.value.bots[0].contextEnhancement.direct.fields, []);
|
||||
assert.equal(cleared.value.bots[0].contextEnhancement.group.guidance, '');
|
||||
assert.equal(cleared.value.bots[0].contextEnhancement.direct.guidance, '');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import * as React from 'react';
|
|||
import TestRenderer from 'react-test-renderer';
|
||||
|
||||
import {
|
||||
CONTEXT_DIRECT_GUIDANCE_EXAMPLE,
|
||||
CONTEXT_ENHANCEMENT_FIELDS,
|
||||
CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH,
|
||||
CONTEXT_GUIDANCE_EXAMPLE,
|
||||
CONTEXT_GROUP_GUIDANCE_EXAMPLE,
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
} from '../src/channels/shared/context-enhancement.mjs';
|
||||
import { ContextEnhancementEditor, contextEnhancementLabel } from '../plugin-src/client/context-enhancement.js';
|
||||
|
|
@ -42,12 +43,40 @@ function button(root, name) {
|
|||
return found;
|
||||
}
|
||||
|
||||
function fields(root) {
|
||||
return root.findAllByType('input').filter((node) => CONTEXT_ENHANCEMENT_FIELDS.includes(node.props.name));
|
||||
function scope(root, kind) {
|
||||
return root.findAllByType('fieldset').find((node) => node.props['data-context-kind'] === kind);
|
||||
}
|
||||
|
||||
function switches(root) {
|
||||
return root.findAllByProps({ role: 'switch' });
|
||||
function fields(root, kind) {
|
||||
const parent = kind ? scope(root, kind) : root;
|
||||
return parent.findAllByType('input').filter((node) => (
|
||||
typeof node.props.name === 'string'
|
||||
&& CONTEXT_ENHANCEMENT_FIELDS.includes(node.props.name.replace(/^(?:group|direct)-/, ''))
|
||||
));
|
||||
}
|
||||
|
||||
function fieldNames(nodes) {
|
||||
return nodes.map((node) => node.props.name.replace(/^(?:group|direct)-/, ''));
|
||||
}
|
||||
|
||||
function guidance(root, kind) {
|
||||
return scope(root, kind).findByType('textarea');
|
||||
}
|
||||
|
||||
function scopeSwitch(root, kind) {
|
||||
return scope(root, kind).findByProps({ role: 'switch' });
|
||||
}
|
||||
|
||||
function switchStates(root) {
|
||||
return ['group', 'direct'].map((kind) => scopeSwitch(root, kind).props.checked);
|
||||
}
|
||||
|
||||
function tabs(root) {
|
||||
return root.findAllByProps({ role: 'tab' });
|
||||
}
|
||||
|
||||
function panels(root) {
|
||||
return root.findAllByProps({ role: 'tabpanel' });
|
||||
}
|
||||
|
||||
function badge(root) {
|
||||
|
|
@ -66,6 +95,10 @@ async function click(root, label) {
|
|||
await act(async () => { button(root, label).props.onClick(); await flush(); });
|
||||
}
|
||||
|
||||
async function clickScope(root, kind, label) {
|
||||
await act(async () => { button(scope(root, kind), label).props.onClick(); await flush(); });
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
|
|
@ -118,7 +151,10 @@ test('context settings default to off with sender ID and empty guidance, and exp
|
|||
[false, false, '未开启'], [true, false, '仅群聊'],
|
||||
[false, true, '仅私聊'], [true, true, '群聊和私聊'],
|
||||
]) {
|
||||
const config = { ...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG, groupEnabled, directEnabled };
|
||||
const config = {
|
||||
group: { ...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.group, enabled: groupEnabled },
|
||||
direct: { ...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.direct, enabled: directEnabled },
|
||||
};
|
||||
assert.equal(contextEnhancementLabel(config), label);
|
||||
}
|
||||
const saved = [];
|
||||
|
|
@ -132,26 +168,50 @@ test('context settings default to off with sender ID and empty guidance, and exp
|
|||
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': '查看来源字段说明' });
|
||||
assert.deepEqual(tabs(renderer.root).map(textOf), ['私聊', '群聊']);
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props['aria-selected']), [true, false]);
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props.tabIndex), [0, -1]);
|
||||
assert.deepEqual(panels(renderer.root).map((node) => node.props.hidden), [false, true]);
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props['aria-controls']), panels(renderer.root).map((node) => node.props.id));
|
||||
let prevented = false;
|
||||
await act(async () => {
|
||||
tabs(renderer.root)[0].props.onKeyDown({ key: 'ArrowRight', preventDefault() { prevented = true; } });
|
||||
});
|
||||
assert.equal(prevented, true);
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props['aria-selected']), [false, true]);
|
||||
assert.deepEqual(panels(renderer.root).map((node) => node.props.hidden), [true, false]);
|
||||
await act(async () => { tabs(renderer.root)[1].props.onKeyDown({ key: 'Home', preventDefault() {} }); });
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props['aria-selected']), [true, false]);
|
||||
assert.deepEqual(renderer.root.findAllByProps({ className: 'dim-contextSwitchRow' }).map(textOf), ['启用', '启用']);
|
||||
assert.deepEqual(renderer.root.findAllByType('label').filter((node) => (
|
||||
typeof node.props.htmlFor === 'string' && node.props.htmlFor.endsWith('-guidance')
|
||||
)).map(textOf), ['增强提示词', '增强提示词']);
|
||||
assert.deepEqual(switchStates(renderer.root), [false, false]);
|
||||
assert.ok(renderer.root.findAllByType('textarea').every((node) => node.props.rows === 4));
|
||||
for (const kind of ['group', 'direct']) {
|
||||
assert.deepEqual(fieldNames(fields(renderer.root, kind)), CONTEXT_ENHANCEMENT_FIELDS);
|
||||
assert.deepEqual(fieldNames(fields(renderer.root, kind).filter((node) => node.props.checked)), ['senderId']);
|
||||
}
|
||||
assert.deepEqual(renderer.root.findAllByProps({ className: 'dim-contextFieldKey' }).map(textOf), [
|
||||
...CONTEXT_ENHANCEMENT_FIELDS, ...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': '查看增强提示词使用说明' });
|
||||
assert.equal(guidance(renderer.root, 'group').props.value, '');
|
||||
assert.equal(guidance(renderer.root, 'direct').props.value, '');
|
||||
assert.equal(guidance(renderer.root, 'group').props.placeholder, CONTEXT_GROUP_GUIDANCE_EXAMPLE);
|
||||
assert.equal(guidance(renderer.root, 'direct').props.placeholder, CONTEXT_DIRECT_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.match(textOf(tooltip), /使用说明.*dsh_im_source.*生效规则.*清空并保存.*隐私提示.*会话历史.*使用示例.*群聊/s);
|
||||
assert.equal(textOf(tooltip.findByProps({ className: 'dim-contextTooltipExample' })), CONTEXT_GROUP_GUIDANCE_EXAMPLE);
|
||||
assert.equal(guidance(renderer.root, 'group').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 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');
|
||||
|
|
@ -165,18 +225,18 @@ test('switches, fields, and guidance are local drafts until Save; Cancel and clo
|
|||
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 () => { scopeSwitch(renderer.root, 'group').props.onChange({ target: { checked: true } }); });
|
||||
assert.deepEqual(switchStates(renderer.root), [true, false]);
|
||||
await act(async () => { scopeSwitch(renderer.root, 'direct').props.onChange({ target: { checked: true } }); });
|
||||
assert.deepEqual(switchStates(renderer.root), [true, true]);
|
||||
await act(async () => {
|
||||
switches(renderer.root)[0].props.onChange({ target: { checked: false } });
|
||||
fields(renderer.root)[0].props.onChange({ target: { checked: false } });
|
||||
scopeSwitch(renderer.root, 'group').props.onChange({ target: { checked: false } });
|
||||
fields(renderer.root, 'group')[0].props.onChange({ target: { checked: false } });
|
||||
});
|
||||
assert.deepEqual(switches(renderer.root).map((node) => node.props.checked), [false, true]);
|
||||
await click(renderer.root, '清空');
|
||||
assert.deepEqual(switchStates(renderer.root), [false, true]);
|
||||
await clickScope(renderer.root, 'group', '清空');
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
assert.equal(renderer.root.findByType('textarea').props.value, '');
|
||||
assert.equal(guidance(renderer.root, 'group').props.value, '');
|
||||
assert.deepEqual(saved, []);
|
||||
await act(async () => {
|
||||
if (dismiss === 'close') renderer.root.findByProps({ 'aria-label': '关闭弹窗' }).props.onClick();
|
||||
|
|
@ -191,9 +251,11 @@ test('switches, fields, and guidance are local drafts until Save; Cancel and clo
|
|||
});
|
||||
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, '');
|
||||
assert.deepEqual(switchStates(renderer.root), [false, false]);
|
||||
for (const kind of ['group', 'direct']) {
|
||||
assert.deepEqual(fieldNames(fields(renderer.root, kind).filter((node) => node.props.checked)), ['senderId']);
|
||||
assert.equal(guidance(renderer.root, kind).props.value, '');
|
||||
}
|
||||
await click(renderer.root, '取消');
|
||||
}
|
||||
assert.deepEqual(saved, []);
|
||||
|
|
@ -211,30 +273,39 @@ test('Save submits one complete config, preserves explicit empty fields/guidance
|
|||
}
|
||||
const renderer = await mount(t, Fixture);
|
||||
await open(renderer.root);
|
||||
await act(async () => { switches(renderer.root)[0].props.onChange({ target: { checked: true } }); });
|
||||
await act(async () => { scopeSwitch(renderer.root, 'group').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 act(async () => {
|
||||
scope(renderer.root, 'group').findByProps({ name: `group-${name}` }).props.onChange({ target: { checked: false } });
|
||||
});
|
||||
}
|
||||
await click(renderer.root, '清空');
|
||||
await clickScope(renderer.root, 'group', '清空');
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(saved, { groupEnabled: true, directEnabled: false, fields: [], guidance: '' });
|
||||
assert.deepEqual(saved, {
|
||||
group: { enabled: true, fields: [], guidance: '' },
|
||||
direct: { enabled: false, fields: ['senderId'], 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]);
|
||||
assert.ok(fields(renderer.root, 'group').every((node) => !node.props.checked));
|
||||
assert.deepEqual(fieldNames(fields(renderer.root, 'direct').filter((node) => node.props.checked)), ['senderId']);
|
||||
assert.equal(guidance(renderer.root, 'group').props.value, '');
|
||||
assert.equal(guidance(renderer.root, 'group').props.placeholder, CONTEXT_GROUP_GUIDANCE_EXAMPLE);
|
||||
await clickScope(renderer.root, 'group', '填入示例');
|
||||
assert.equal(guidance(renderer.root, 'group').props.value, CONTEXT_GROUP_GUIDANCE_EXAMPLE);
|
||||
assert.equal(guidance(renderer.root, 'direct').props.value, '');
|
||||
assert.ok(fields(renderer.root, 'group').every((node) => !node.props.checked));
|
||||
assert.deepEqual(switchStates(renderer.root), [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));
|
||||
assert.equal(guidance(reloaded.root, 'group').props.value, '');
|
||||
assert.equal(guidance(reloaded.root, 'direct').props.value, '');
|
||||
assert.ok(fields(reloaded.root, 'group').every((node) => !node.props.checked));
|
||||
assert.deepEqual(fieldNames(fields(reloaded.root, 'direct').filter((node) => node.props.checked)), ['senderId']);
|
||||
});
|
||||
|
||||
test('failed atomic saves retain the draft, lock edits and duplicate submits, and can be retried', async (t) => {
|
||||
|
|
@ -246,8 +317,8 @@ test('failed atomic saves retain the draft, lock edits and duplicate submits, an
|
|||
});
|
||||
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' } });
|
||||
scopeSwitch(renderer.root, 'direct').props.onChange({ target: { checked: true } });
|
||||
guidance(renderer.root, 'direct').props.onChange({ target: { value: 'local draft' } });
|
||||
});
|
||||
const saveButton = button(renderer.root, '保存');
|
||||
await act(async () => { saveButton.props.onClick(); saveButton.props.onClick(); await flush(); });
|
||||
|
|
@ -256,18 +327,18 @@ test('failed atomic saves retain the draft, lock edits and duplicate submits, an
|
|||
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' } });
|
||||
guidance(renderer.root, 'direct').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(guidance(renderer.root, 'direct').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(guidance(renderer.root, 'direct').props.value, 'local draft');
|
||||
assert.deepEqual(switchStates(renderer.root), [false, true]);
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(calls.length, 2);
|
||||
|
|
@ -279,25 +350,39 @@ 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 },
|
||||
config: {
|
||||
group: { enabled: true, fields: ['botId'], guidance: 'preserve group' },
|
||||
direct: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG.direct,
|
||||
},
|
||||
onSave(value) { saved.push(value); },
|
||||
});
|
||||
assert.equal(badge(renderer.root), '未开启');
|
||||
await open(renderer.root);
|
||||
const group = switches(renderer.root)[0];
|
||||
assert.deepEqual(tabs(renderer.root).map((node) => node.props['aria-selected']), [true, false]);
|
||||
assert.deepEqual(panels(renderer.root).map((node) => node.props.hidden), [false, true]);
|
||||
const group = scopeSwitch(renderer.root, 'group');
|
||||
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');
|
||||
assert.ok(scope(renderer.root, 'group').findAllByType('input').every((node) => node.props.disabled));
|
||||
assert.equal(guidance(renderer.root, 'group').props.disabled, true);
|
||||
await act(async () => { tabs(renderer.root)[1].props.onClick(); });
|
||||
assert.deepEqual(panels(renderer.root).map((node) => node.props.hidden), [true, false]);
|
||||
await act(async () => {
|
||||
group.props.onChange({ target: { checked: true } });
|
||||
switches(renderer.root)[1].props.onChange({ target: { checked: true } });
|
||||
tabs(renderer.root)[0].props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
scopeSwitch(renderer.root, 'direct').props.onChange({ target: { checked: true } });
|
||||
});
|
||||
await click(renderer.root, '保存');
|
||||
assert.equal(saved[0].groupEnabled, false);
|
||||
assert.equal(saved[0].directEnabled, true);
|
||||
assert.equal(saved[0].group.enabled, false);
|
||||
assert.deepEqual(saved[0].group.fields, ['botId']);
|
||||
assert.equal(saved[0].group.guidance, 'preserve group');
|
||||
assert.equal(saved[0].direct.enabled, true);
|
||||
});
|
||||
|
||||
test('dialog traps Tab and external focus, cancels with Escape, and restores entry focus', async (t) => {
|
||||
|
|
@ -349,16 +434,16 @@ test('all context dialog copy and validation errors localize without translating
|
|||
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');
|
||||
assert.equal(guidance(renderer.root, 'direct').props.value, '');
|
||||
assert.equal(guidance(renderer.root, 'direct').props.placeholder, en[CONTEXT_DIRECT_GUIDANCE_EXAMPLE]);
|
||||
assert.equal(guidance(renderer.root, 'direct').props.maxLength, CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH);
|
||||
const localizedExample = textOf(scope(renderer.root, 'direct').findByProps({ className: 'dim-contextTooltipExample' }));
|
||||
assert.equal(localizedExample, en[CONTEXT_DIRECT_GUIDANCE_EXAMPLE]);
|
||||
await clickScope(renderer.root, 'direct', 'Use example');
|
||||
assert.equal(guidance(renderer.root, 'direct').props.value, localizedExample);
|
||||
await clickScope(renderer.root, 'direct', 'Clear');
|
||||
await act(async () => {
|
||||
renderer.root.findByType('textarea').props.onChange({ target: { value: 'x'.repeat(CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH + 1) } });
|
||||
guidance(renderer.root, 'direct').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/);
|
||||
|
|
@ -368,20 +453,32 @@ test('all context dialog copy and validation errors localize without translating
|
|||
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 empty = {
|
||||
group: { enabled: true, fields: [], guidance: '' },
|
||||
direct: { enabled: false, fields: ['senderId'], 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 ',
|
||||
group: { enabled: false, fields: ['botId', 'channel', 'botId'], guidance: ' \n ' },
|
||||
direct: { enabled: true, fields: [], guidance: 'direct only' },
|
||||
}]));
|
||||
assert.deepEqual(canonical.bots[0].contextEnhancement, {
|
||||
groupEnabled: false, directEnabled: true, fields: ['channel', 'botId'], guidance: '',
|
||||
group: { enabled: false, fields: ['channel', 'botId'], guidance: '' },
|
||||
direct: { enabled: true, fields: [], guidance: 'direct only' },
|
||||
}, channel.name);
|
||||
const legacy = channel.normalize(snapshot(channel.name, [{
|
||||
groupEnabled: true, directEnabled: false, fields: ['botId'], guidance: 'legacy',
|
||||
}]));
|
||||
assert.deepEqual(legacy.bots[0].contextEnhancement, {
|
||||
group: { enabled: true, fields: ['botId'], guidance: 'legacy' },
|
||||
direct: { enabled: false, fields: ['botId'], guidance: 'legacy' },
|
||||
}, 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.equal(damaged.bots[0].contextEnhancement.group.enabled, false, channel.name);
|
||||
assert.equal(damaged.bots[0].contextEnhancement.direct.enabled, false, channel.name);
|
||||
assert.deepEqual(raw.bots[1].contextEnhancement, empty);
|
||||
}
|
||||
});
|
||||
|
|
@ -406,18 +503,21 @@ test('all nine cards save through their existing RPC path, isolate bots and pres
|
|||
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 } }); });
|
||||
await act(async () => { scopeSwitch(first(), 'direct').props.onChange({ target: { checked: true } }); });
|
||||
for (const name of CONTEXT_ENHANCEMENT_FIELDS) await act(async () => {
|
||||
first().findByProps({ name }).props.onChange({ target: { checked: false } });
|
||||
scope(first(), 'direct').findByProps({ name: `direct-${name}` }).props.onChange({ target: { checked: false } });
|
||||
});
|
||||
await click(first(), '清空');
|
||||
await clickScope(first(), 'direct', '清空');
|
||||
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: '' },
|
||||
botId: `${channel.name}_0`, config: {
|
||||
group: { enabled: false, fields: ['senderId'], guidance: '' },
|
||||
direct: { enabled: true, fields: [], guidance: '' },
|
||||
},
|
||||
} }]);
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
assert.equal(badge(second()), '未开启');
|
||||
|
|
@ -425,8 +525,9 @@ test('all nine cards save through their existing RPC path, isolate bots and pres
|
|||
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));
|
||||
assert.equal(guidance(reloaded.root, 'direct').props.value, '');
|
||||
assert.ok(fields(reloaded.root, 'direct').every((node) => !node.props.checked));
|
||||
assert.deepEqual(fieldNames(fields(reloaded.root, 'group').filter((node) => node.props.checked)), ['senderId']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -437,7 +538,10 @@ test('all nine settings fence stale polls and reconcile against the actual saved
|
|||
const original = snapshot(channel.name);
|
||||
let current = original;
|
||||
let reads = 0;
|
||||
const actual = { groupEnabled: false, directEnabled: true, fields: ['botId'], guidance: '' };
|
||||
const actual = {
|
||||
group: { enabled: false, fields: ['senderId'], guidance: '' },
|
||||
direct: { enabled: true, fields: ['botId'], guidance: '' },
|
||||
};
|
||||
const rpcCall = async (endpoint) => {
|
||||
if (endpoint === 'connection.status') {
|
||||
reads += 1;
|
||||
|
|
@ -459,8 +563,8 @@ test('all nine settings fence stale polls and reconcile against the actual saved
|
|||
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, '');
|
||||
assert.deepEqual(fieldNames(fields(first(), 'direct').filter((node) => node.props.checked)), ['botId']);
|
||||
assert.equal(guidance(first(), 'direct').props.value, '');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -482,7 +586,7 @@ test('all nine settings ignore an older concurrent bot mutation after context se
|
|||
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 act(async () => { scopeSwitch(first(), 'direct').props.onChange({ target: { checked: true } }); });
|
||||
await click(first(), '保存');
|
||||
assert.equal(badge(first()), '仅私聊');
|
||||
reconnect.resolve({ ok: true, value: original });
|
||||
|
|
@ -506,20 +610,20 @@ test('all nine failed save RPCs keep runtime state and drafts intact through sta
|
|||
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 act(async () => { scopeSwitch(first(), 'direct').props.onChange({ target: { checked: true } }); });
|
||||
await clickScope(first(), 'direct', '清空');
|
||||
await click(first(), '保存');
|
||||
assert.equal(badge(first()), '未开启');
|
||||
assert.equal(switches(first())[1].props.checked, true);
|
||||
assert.equal(scopeSwitch(first(), 'direct').props.checked, true);
|
||||
const dialog = first().findByProps({ role: 'dialog' });
|
||||
assert.equal(dialog.findByType('textarea').props.value, '');
|
||||
assert.equal(guidance(first(), 'direct').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, '');
|
||||
assert.equal(scopeSwitch(first(), 'direct').props.checked, false);
|
||||
assert.equal(guidance(first(), 'direct').props.value, '');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -528,8 +632,10 @@ test('the approved neutral entry and theme-aware modal keep responsive labels an
|
|||
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-contextTabs \{[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);[^}]*border-radius: 8px;/);
|
||||
assert.match(styles, /\.dim-contextTab\[aria-selected="true"\] \{[^}]*--dsw-alias-state-business-primary[^}]*box-shadow:/);
|
||||
assert.match(styles, /\.dim-contextTabPanel\[hidden\] \{[^}]*display: none;/);
|
||||
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/);
|
||||
|
|
|
|||
|
|
@ -13,12 +13,19 @@ import {
|
|||
} from '../src/channels/shared/context-enhancement.mjs';
|
||||
|
||||
function config(overrides = {}) {
|
||||
const {
|
||||
groupEnabled = true,
|
||||
directEnabled = false,
|
||||
fields = CONTEXT_ENHANCEMENT_FIELDS,
|
||||
guidance = '',
|
||||
group = {},
|
||||
direct = {},
|
||||
...extra
|
||||
} = overrides;
|
||||
return {
|
||||
...DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
groupEnabled: true,
|
||||
fields: CONTEXT_ENHANCEMENT_FIELDS,
|
||||
guidance: '',
|
||||
...overrides,
|
||||
group: { enabled: groupEnabled, fields, guidance, ...group },
|
||||
direct: { enabled: directEnabled, fields, guidance, ...direct },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -32,29 +39,52 @@ function sourceOf(text) {
|
|||
}
|
||||
|
||||
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 kind of ['group', 'direct']) {
|
||||
assert.equal(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG[kind].enabled, false);
|
||||
assert.equal(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG[kind].guidance, '');
|
||||
assert.deepEqual(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG[kind].fields, ['senderId']);
|
||||
assert.equal(Object.isFrozen(DEFAULT_CONTEXT_ENHANCEMENT_CONFIG[kind].fields), true);
|
||||
}
|
||||
for (const guidance of ['', ' \r\n\t ']) {
|
||||
assert.deepEqual(validateContextEnhancementConfig(config({ fields: [], guidance })), {
|
||||
groupEnabled: true, directEnabled: false, fields: [], guidance: '',
|
||||
group: { enabled: true, fields: [], guidance: '' },
|
||||
direct: { enabled: 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.deepEqual(canonical.group.fields, ['channel', 'senderName', 'botId']);
|
||||
assert.deepEqual(canonical.direct.fields, ['channel', 'senderName', 'botId']);
|
||||
assert.equal(canonical.group.guidance, ' custom text\n');
|
||||
assert.equal(Object.isFrozen(canonical), true);
|
||||
assert.equal(Object.isFrozen(canonical.fields), true);
|
||||
assert.equal(Object.isFrozen(canonical.group), true);
|
||||
assert.equal(Object.isFrozen(canonical.group.fields), true);
|
||||
});
|
||||
|
||||
test('legacy context settings migrate in memory without weakening new-save validation', () => {
|
||||
const legacy = {
|
||||
groupEnabled: true,
|
||||
directEnabled: false,
|
||||
fields: ['botId', 'channel', 'botId'],
|
||||
guidance: 'legacy guidance',
|
||||
};
|
||||
assert.throws(() => validateContextEnhancementConfig(legacy));
|
||||
assert.deepEqual(normalizeContextEnhancementConfig(legacy), {
|
||||
group: { enabled: true, fields: ['channel', 'botId'], guidance: 'legacy guidance' },
|
||||
direct: { enabled: false, fields: ['channel', 'botId'], guidance: 'legacy guidance' },
|
||||
});
|
||||
assert.equal(normalizeContextEnhancementConfig({
|
||||
...legacy,
|
||||
group: { enabled: true, fields: [], guidance: 'mixed' },
|
||||
}), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
|
||||
});
|
||||
|
||||
test('context saves reject missing, unknown and mistyped fields while normalization fails off', () => {
|
||||
const invalid = [
|
||||
undefined, null, [], {}, true,
|
||||
{ ...config(), unexpected: true },
|
||||
{ group: config().group },
|
||||
{ groupEnabled: true, directEnabled: false, fields: [] },
|
||||
config({ groupEnabled: 'true' }), config({ directEnabled: 1 }),
|
||||
config({ fields: null }), config({ fields: 'channel' }),
|
||||
|
|
@ -63,7 +93,7 @@ test('context saves reject missing, unknown and mistyped fields while normalizat
|
|||
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'); } },
|
||||
{ ...config(), group: { ...config().group, get guidance() { throw new Error('bad guidance'); } } },
|
||||
];
|
||||
for (const value of invalid) {
|
||||
assert.throws(() => validateContextEnhancementConfig(value));
|
||||
|
|
@ -72,7 +102,7 @@ test('context saves reject missing, unknown and mistyped fields while normalizat
|
|||
}
|
||||
assert.equal(validateContextEnhancementConfig(config({
|
||||
guidance: 'x'.repeat(CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH),
|
||||
})).guidance.length, CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH);
|
||||
})).group.guidance.length, CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH);
|
||||
});
|
||||
|
||||
test('off path preserves content identity and never reads fields, guidance, source or formatter', (t) => {
|
||||
|
|
@ -81,9 +111,16 @@ test('off path preserves content identity and never reads fields, guidance, sour
|
|||
let settingReads = 0;
|
||||
const throwIfRead = () => { throw new Error('off path must not inspect this'); };
|
||||
const settings = {
|
||||
groupEnabled: false, directEnabled: false,
|
||||
group: {
|
||||
enabled: false,
|
||||
get fields() { return throwIfRead(); },
|
||||
get guidance() { return throwIfRead(); },
|
||||
},
|
||||
direct: {
|
||||
enabled: false,
|
||||
get fields() { return throwIfRead(); },
|
||||
get guidance() { return throwIfRead(); },
|
||||
},
|
||||
};
|
||||
const provider = {
|
||||
get botId() { return throwIfRead(); },
|
||||
|
|
@ -123,14 +160,31 @@ test('only the actual conversation type can enable capture, regardless of select
|
|||
}
|
||||
for (const kind of ['group', 'direct']) {
|
||||
const offSettings = {
|
||||
[kind === 'group' ? 'groupEnabled' : 'directEnabled']: false,
|
||||
[kind]: {
|
||||
enabled: false,
|
||||
get fields() { throw new Error('no field projection'); },
|
||||
get guidance() { throw new Error('no guidance lookup'); },
|
||||
},
|
||||
};
|
||||
assert.equal(snapshot(offSettings, kind), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('group and direct messages use only their own fields and guidance', () => {
|
||||
const settings = config({
|
||||
group: { enabled: true, fields: ['channel'], guidance: 'GROUP-ONLY-TOKEN' },
|
||||
direct: { enabled: true, fields: ['botId'], guidance: 'DIRECT-ONLY-TOKEN' },
|
||||
});
|
||||
const group = enhanceContextContent('group text', snapshot(settings, 'group'), () => ({ channel: 'feishu' }));
|
||||
const direct = enhanceContextContent('direct text', snapshot(settings, 'direct'), () => ({ channel: 'feishu' }));
|
||||
assert.deepEqual(sourceOf(group), { channel: 'feishu' });
|
||||
assert.match(group, /GROUP-ONLY-TOKEN/);
|
||||
assert.doesNotMatch(group, /DIRECT-ONLY-TOKEN|botId/);
|
||||
assert.deepEqual(sourceOf(direct), { botId: 'bot_one' });
|
||||
assert.match(direct, /DIRECT-ONLY-TOKEN/);
|
||||
assert.doesNotMatch(direct, /GROUP-ONLY-TOKEN|channel/);
|
||||
});
|
||||
|
||||
test('all 32 source-field subsets are projected in canonical order with no hidden fields', () => {
|
||||
const expected = {
|
||||
channel: 'telegram', conversationType: 'group',
|
||||
|
|
@ -231,9 +285,9 @@ test('queued snapshots are immutable and retain a complete setting version after
|
|||
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;
|
||||
settings.group.fields.push('senderName');
|
||||
settings.group.guidance = 'new guidance';
|
||||
settings.group.enabled = false;
|
||||
assert.equal(captureContextEnhancement(provider, 'group'), null);
|
||||
assert.deepEqual(before.config.fields, ['senderId']);
|
||||
const result = enhanceContextContent('queued', before, () => ({ senderId: 'old', senderName: 'new' }));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue