diff --git a/.github/workflows/packages-release.yml b/.github/workflows/packages-release.yml index a327ac1e..106f9443 100644 --- a/.github/workflows/packages-release.yml +++ b/.github/workflows/packages-release.yml @@ -6,7 +6,7 @@ on: description: Independent npm package type: choice required: true - options: [dsh-external-agents, dsh-decision, dsh-annotation, dsh-side-chat, dsh-desktop-pet, dsh-literature, dsh-oidc, dsh-memory, dsh-mail, dsh-artifact-services, dsh-knowledge-studio, dsh-remote-access] + options: [dsh-external-agents, dsh-decision, dsh-annotation, dsh-side-chat, dsh-message-edit, dsh-desktop-pet, dsh-literature, dsh-oidc, dsh-memory, dsh-mail, dsh-artifact-services, dsh-knowledge-studio, dsh-remote-access] publish: description: Publish inspected tarball (requires reviewed source commit and npm trusted publisher) type: boolean @@ -50,7 +50,7 @@ jobs: run: python -m pip install -r packages/dsh-knowledge-studio/packages/artifact-services/python/requirements.txt - run: node scripts/packages/manage.mjs install ${{ inputs.package }} - name: Install locked DSH Runtime for native plugin checks - if: inputs.package == 'dsh-side-chat' || inputs.package == 'dsh-decision' + if: inputs.package == 'dsh-side-chat' || inputs.package == 'dsh-message-edit' || inputs.package == 'dsh-decision' working-directory: third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime run: npm ci --ignore-scripts --no-audit --no-fund - run: node scripts/packages/manage.mjs check ${{ inputs.package }} @@ -59,6 +59,7 @@ jobs: PYTHONUTF8: '1' EDUWORK_SIDE_CHAT_RUNTIME: ${{ inputs.package == 'dsh-side-chat' && format('{0}/third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime', github.workspace) || '' }} EDUWORK_DECISION_RUNTIME: ${{ inputs.package == 'dsh-decision' && format('{0}/third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime', github.workspace) || '' }} + EDUWORK_MESSAGE_EDIT_RUNTIME: ${{ inputs.package == 'dsh-message-edit' && format('{0}/third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime', github.workspace) || '' }} - run: node scripts/packages/manage.mjs pack ${{ inputs.package }} dist/npm-release - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 44c093fe..221d3770 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -50,7 +50,7 @@ jobs: run: python -m pip install -r packages/dsh-knowledge-studio/packages/artifact-services/python/requirements.txt - run: node scripts/packages/manage.mjs install ${{ matrix.package }} - name: Install locked DSH Runtime for native plugin checks - if: matrix.package == 'dsh-side-chat' || matrix.package == 'dsh-decision' + if: matrix.package == 'dsh-side-chat' || matrix.package == 'dsh-message-edit' || matrix.package == 'dsh-decision' working-directory: third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime run: npm ci --ignore-scripts --no-audit --no-fund - run: node scripts/packages/manage.mjs check ${{ matrix.package }} @@ -59,6 +59,7 @@ jobs: PYTHONUTF8: '1' EDUWORK_SIDE_CHAT_RUNTIME: ${{ matrix.package == 'dsh-side-chat' && format('{0}/third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime', github.workspace) || '' }} EDUWORK_DECISION_RUNTIME: ${{ matrix.package == 'dsh-decision' && format('{0}/third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime', github.workspace) || '' }} + EDUWORK_MESSAGE_EDIT_RUNTIME: ${{ matrix.package == 'dsh-message-edit' && format('{0}/third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime', github.workspace) || '' }} - run: node scripts/packages/manage.mjs pack ${{ matrix.package }} - if: matrix.package == 'dsh-knowledge-studio' run: node scripts/packages/manage.mjs pack dsh-artifact-services diff --git a/docs/PACKAGES.md b/docs/PACKAGES.md index c8cf10b1..432f473d 100644 --- a/docs/PACKAGES.md +++ b/docs/PACKAGES.md @@ -12,6 +12,7 @@ EduWork 在一个仓库维护公共插件,npm 包仍分别安装、版本管 | `@eduwork/dsh-decision` | [SystemOne 决策工具与技能](../packages/dsh-decision/README.md) | `packages/dsh-decision` | | `@eduwork/dsh-annotation` | [选段引用与批注](../packages/dsh-annotation/README.md) | `packages/dsh-annotation` | | `@eduwork/dsh-side-chat` | [侧边聊天与选区提问](../packages/dsh-side-chat/README.md) | `packages/dsh-side-chat` | +| `@eduwork/dsh-message-edit` | [编辑消息](../packages/dsh-message-edit/README.md) | `packages/dsh-message-edit` | | `@eduwork/dsh-desktop-pet` | [桌面宠物与宿主要求](../packages/dsh-desktop-pet/README.md) | `packages/dsh-desktop-pet` | | `@eduwork/dsh-literature` | [文献检索、引用与全文](../packages/dsh-literature/README.md) | `packages/dsh-literature` | | `@eduwork/dsh-oidc` | [身份、凭据与模型接入](../packages/dsh-oidc/README.md) | `packages/dsh-oidc` | @@ -29,6 +30,8 @@ EduWork 在一个仓库维护公共插件,npm 包仍分别安装、版本管 决策插件使用 DSH 原生 bundle、工具和技能,从已发布的 npm 原包预装。决策服务默认关闭;支持显式 API Key、可选 OIDC 和免鉴权服务配置,配置完成后启用。包检查使用锁定的 rc.5 Runtime(`EDUWORK_DECISION_RUNTIME`),测试不请求真实模型。 +编辑消息插件用 DSH 原生分叉重发改过的消息,尚未加入桌面装配。构建和包检查使用锁定的 DSH `0.2.2-rc.1` Runtime(`EDUWORK_MESSAGE_EDIT_RUNTIME`),测试不请求真实模型;浏览器验收脚本在本地运行,不进普通 CI。 + `peerDependencies` 表示插件支持的宿主 API 范围;开发依赖、`package-lock.json` 和产品组件锁记录一次构建的确切输入,两者分别维护。对已验证兼容的同一 API 系列,peer 应使用有上下界的版本范围,避免逐个追加 rc 版本。范围需要检查预发布版本的匹配语义,并在支持范围内验证实际插件加载与工具调用;不能仅因版本号相邻就假定兼容,也不使用 `*` 跳过边界。 内核升级时先核对 API 与行为:兼容声明已覆盖且验证通过的包直接复用现有 npm 产物,不因宿主升级重发。若已发布包的声明过窄,修正元数据仍需新的 npm 版本,不能覆盖旧包,但这不应成为每次 rc 升级的固定步骤。文献包从 `0.1.2` 起声明 `>=0.2.0-rc.1 <0.3.0-0`,检索实现不变。 diff --git a/docs/PACKAGES_EN.md b/docs/PACKAGES_EN.md index aef08a7b..7813b549 100644 --- a/docs/PACKAGES_EN.md +++ b/docs/PACKAGES_EN.md @@ -10,6 +10,7 @@ EduWork maintains its public plugins in one source repository. Each npm package | `@eduwork/dsh-decision` | [SystemOne decision tool and skill](../packages/dsh-decision/README_EN.md) | `packages/dsh-decision` | | `@eduwork/dsh-annotation` | [Selection quotes and notes](../packages/dsh-annotation/README_EN.md) | `packages/dsh-annotation` | | `@eduwork/dsh-side-chat` | [Side conversations and selection questions](../packages/dsh-side-chat/README_EN.md) | `packages/dsh-side-chat` | +| `@eduwork/dsh-message-edit` | [Message editing](../packages/dsh-message-edit/README_EN.md) | `packages/dsh-message-edit` | | `@eduwork/dsh-desktop-pet` | [Desktop pet and host requirements](../packages/dsh-desktop-pet/README_EN.md) | `packages/dsh-desktop-pet` | | `@eduwork/dsh-literature` | [Literature search, citations and full text](../packages/dsh-literature/README_EN.md) | `packages/dsh-literature` | | `@eduwork/dsh-oidc` | [Identity, credentials and models](../packages/dsh-oidc/README_EN.md) | `packages/dsh-oidc` | @@ -27,6 +28,8 @@ Side chat independently opens from message actions. With annotation `>=0.1.1 <0. The decision plugin uses native DSH bundles, tools and skills and is preinstalled from its published npm archive. The decision service remains disabled until configured and enabled. It supports explicit API-key, optional OIDC and unauthenticated service configuration. Checks use the locked rc.5 Runtime (`EDUWORK_DECISION_RUNTIME`) without live model requests. +The message-editing plugin resends an edited message through a native DSH fork and is not yet part of desktop assembly. Its build and checks use the locked DSH `0.2.2-rc.1` Runtime (`EDUWORK_MESSAGE_EDIT_RUNTIME`) without live model requests; its browser acceptance script runs locally, outside regular CI. + Use each manifest and lock for Node/DSH requirements; common CI uses Node 24.22.1. From the repository root: ```sh diff --git a/packages/dsh-message-edit/.gitignore b/packages/dsh-message-edit/.gitignore new file mode 100644 index 00000000..c3af8579 --- /dev/null +++ b/packages/dsh-message-edit/.gitignore @@ -0,0 +1 @@ +lib/ diff --git a/packages/dsh-message-edit/CHANGELOG.md b/packages/dsh-message-edit/CHANGELOG.md new file mode 100644 index 00000000..d4a60164 --- /dev/null +++ b/packages/dsh-message-edit/CHANGELOG.md @@ -0,0 +1,14 @@ +# 变更记录 / Changelog + +## 0.1.0 — Unreleased + +- 首个版本:用户消息旁的铅笔按钮打开编辑框,改过的消息从 DSH 原生分叉的新对话里重新发送,新对话接替原对话(保留用户设置的标题和置顶),原对话移进归档。 +- 原消息的图片和文件可以逐个移除,也可以选择、粘贴或拖入新的附件;附件在主机内重新登记到新对话,访问控制同时检查原对话和新对话。 +- 消息里引用的其他对话保留到编辑后的消息,用户改动或删除引用文字时去掉。 +- 生成中不能编辑;插话不能单独编辑,编辑这一轮开头的消息时提示插话会一起替换。 +- 同一对话可以同时编辑多条消息,草稿在滚动、切换对话和对话被替换后保留;同一对话一次只发送一条编辑。 +- Initial release: a pencil next to each user message opens an editor; the edited message is resent in a new conversation forked natively by DSH, which takes the original's place (keeping a title the user set and the pin), and the original is archived. +- The message's images and files can be removed one by one, and new attachments picked, pasted or dropped in; attachments are re-staged into the new conversation inside the Host, with access control checking both conversations. +- Conversations the message references stay referenced in the edited message, and are dropped when the user changes or deletes the reference text. +- Messages cannot be edited while a reply generates; steered messages cannot be edited on their own, and editing their turn's first message says they are replaced too. +- Several messages of one conversation can be edited at once; drafts survive scrolling, switching conversations and the conversation being replaced; one edit of a conversation is sent at a time. diff --git a/packages/dsh-message-edit/LICENSE b/packages/dsh-message-edit/LICENSE new file mode 100644 index 00000000..cbafa434 --- /dev/null +++ b/packages/dsh-message-edit/LICENSE @@ -0,0 +1,130 @@ + +木兰宽松许可证, 第2版 + +木兰宽松许可证, 第2版 + +2020年1月 http://license.coscl.org.cn/MulanPSL2 + +您对“软件”的复制、使用、修改及分发受木兰宽松许可证,第2版(“本许可证”)的如下条款的约束: + +0. 定义 + +“软件” 是指由“贡献”构成的许可在“本许可证”下的程序和相关文档的集合。 + +“贡献” 是指由任一“贡献者”许可在“本许可证”下的受版权法保护的作品。 + +“贡献者” 是指将受版权法保护的作品许可在“本许可证”下的自然人或“法人实体”。 + +“法人实体” 是指提交贡献的机构及其“关联实体”。 + +“关联实体” 是指,对“本许可证”下的行为方而言,控制、受控制或与其共同受控制的机构,此处的控制是指有受控方或共同受控方至少50%直接或间接的投票权、资金或其他有价证券。 + +1. 授予版权许可 + +每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的版权许可,您可以复制、使用、修改、分发其“贡献”,不论修改与否。 + +2. 授予专利许可 + +每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的(根据本条规定撤销除外)专利许可,供您制造、委托制造、使用、许诺销售、销售、进口其“贡献”或以其他方式转移其“贡献”。前述专利许可仅限于“贡献者”现在或将来拥有或控制的其“贡献”本身或其“贡献”与许可“贡献”时的“软件”结合而将必然会侵犯的专利权利要求,不包括对“贡献”的修改或包含“贡献”的其他结合。如果您或您的“关联实体”直接或间接地,就“软件”或其中的“贡献”对任何人发起专利侵权诉讼(包括反诉或交叉诉讼)或其他专利维权行动,指控其侵犯专利权,则“本许可证”授予您对“软件”的专利许可自您提起诉讼或发起维权行动之日终止。 + +3. 无商标许可 + +“本许可证”不提供对“贡献者”的商品名称、商标、服务标志或产品名称的商标许可,但您为满足第4条规定的声明义务而必须使用除外。 + +4. 分发限制 + +您可以在任何媒介中将“软件”以源程序形式或可执行形式重新分发,不论修改与否,但您必须向接收者提供“本许可证”的副本,并保留“软件”中的版权、商标、专利及免责声明。 + +5. 免责声明与责任限制 + +“软件”及其中的“贡献”在提供时不带任何明示或默示的担保。在任何情况下,“贡献者”或版权所有者不对任何人因使用“软件”或其中的“贡献”而引发的任何直接或间接损失承担责任,不论因何种原因导致或者基于何种法律理论,即使其曾被建议有此种损失的可能性。 + +6. 语言 + +“本许可证”以中英文双语表述,中英文版本具有同等法律效力。如果中英文版本存在任何冲突不一致,以中文版为准。 + +条款结束 + +如何将木兰宽松许可证,第2版,应用到您的软件 + +如果您希望将木兰宽松许可证,第2版,应用到您的新软件,为了方便接收者查阅,建议您完成如下三步: + +1, 请您补充如下声明中的空白,包括软件名、软件的首次发表年份以及您作为版权人的名字; + +2, 请您在软件包的一级目录下创建以“LICENSE”为名的文件,将整个许可证文本放入该文件中; + +3, 请将如下声明文本放入每个源文件的头部注释中。 + +Copyright (c) [Year] [name of copyright holder] +[Software Name] is licensed under Mulan PSL v2. +You can use this software according to the terms and conditions of the Mulan PSL v2. +You may obtain a copy of Mulan PSL v2 at: + http://license.coscl.org.cn/MulanPSL2 +THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, +EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, +MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. +See the Mulan PSL v2 for more details. + +Mulan Permissive Software License,Version 2 + +Mulan Permissive Software License,Version 2 (Mulan PSL v2) + +January 2020 http://license.coscl.org.cn/MulanPSL2 + +Your reproduction, use, modification and distribution of the Software shall be subject to Mulan PSL v2 (this License) with the following terms and conditions: + +0. Definition + +Software means the program and related documents which are licensed under this License and comprise all Contribution(s). + +Contribution means the copyrightable work licensed by a particular Contributor under this License. + +Contributor means the Individual or Legal Entity who licenses its copyrightable work under this License. + +Legal Entity means the entity making a Contribution and all its Affiliates. + +Affiliates means entities that control, are controlled by, or are under common control with the acting entity under this License, ‘control’ means direct or indirect ownership of at least fifty percent (50%) of the voting power, capital or other securities of controlled or commonly controlled entity. + +1. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable copyright license to reproduce, use, modify, or distribute its Contribution, with modification or not. + +2. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby grants to you a perpetual, worldwide, royalty-free, non-exclusive, irrevocable (except for revocation under this Section) patent license to make, have made, use, offer for sale, sell, import or otherwise transfer its Contribution, where such patent license is only limited to the patent claims owned or controlled by such Contributor now or in future which will be necessarily infringed by its Contribution alone, or by combination of the Contribution with the Software to which the Contribution was contributed. The patent license shall not apply to any modification of the Contribution, and any other combination which includes the Contribution. If you or your Affiliates directly or indirectly institute patent litigation (including a cross claim or counterclaim in a litigation) or other patent enforcement activities against any individual or entity by alleging that the Software or any Contribution in it infringes patents, then any patent license granted to you under this License for the Software shall terminate as of the date such litigation or activity is filed or taken. + +3. No Trademark License + +No trademark license is granted to use the trade names, trademarks, service marks, or product names of Contributor, except as required to fulfill notice requirements in section 4. + +4. Distribution Restriction + +You may distribute the Software in any medium with or without modification, whether in source or executable forms, provided that you provide recipients with a copy of this License and retain copyright, patent, trademark and disclaimer statements in the Software. + +5. Disclaimer of Warranty and Limitation of Liability + +THE SOFTWARE AND CONTRIBUTION IN IT ARE PROVIDED WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED. IN NO EVENT SHALL ANY CONTRIBUTOR OR COPYRIGHT HOLDER BE LIABLE TO YOU FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO ANY DIRECT, OR INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING FROM YOUR USE OR INABILITY TO USE THE SOFTWARE OR THE CONTRIBUTION IN IT, NO MATTER HOW IT’S CAUSED OR BASED ON WHICH LEGAL THEORY, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +6. Language + +THIS LICENSE IS WRITTEN IN BOTH CHINESE AND ENGLISH, AND THE CHINESE VERSION AND ENGLISH VERSION SHALL HAVE THE SAME LEGAL EFFECT. IN THE CASE OF DIVERGENCE BETWEEN THE CHINESE AND ENGLISH VERSIONS, THE CHINESE VERSION SHALL PREVAIL. + +END OF THE TERMS AND CONDITIONS + +How to Apply the Mulan Permissive Software License,Version 2 (Mulan PSL v2) to Your Software + +To apply the Mulan PSL v2 to your work, for easy identification by recipients, you are suggested to complete following three steps: + +i. Fill in the blanks in following statement, including insert your software name, the year of the first publication of your software, and your name identified as the copyright owner; +ii. Create a file named "LICENSE" which contains the whole context of this License in the first directory of your software package; +iii. Attach the statement to the appropriate annotated syntax at the beginning of each source file. + +Copyright (c) [Year] [name of copyright holder] +[Software Name] is licensed under Mulan PSL v2. +You can use this software according to the terms and conditions of the Mulan PSL v2. +You may obtain a copy of Mulan PSL v2 at: + http://license.coscl.org.cn/MulanPSL2 +THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, +EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, +MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. +See the Mulan PSL v2 for more details. diff --git a/packages/dsh-message-edit/README.md b/packages/dsh-message-edit/README.md new file mode 100644 index 00000000..93c63e86 --- /dev/null +++ b/packages/dsh-message-edit/README.md @@ -0,0 +1,73 @@ +# 编辑消息 + +**简体中文** | [English](README_EN.md) + +`@eduwork/dsh-message-edit` 让用户修改已经发出的消息,再从这条消息重新发送。用法和 Codex App 的“编辑消息”一样:改完发送后,对话从这条消息起换成新的内容,后面的旧消息和回复不再显示。 + +DSH 的会话记录只能追加,不能原地修改,所以插件用 DSH 原生的分叉实现:从这条消息之前分出一个新对话,把改过的消息作为新对话的下一轮发出,再让新对话接替原对话。原对话不做任何改动,移进归档,随时可以恢复。插件不修改 DSH 的 Runtime 或界面 bundle。 + +## 使用 + +在用户消息下方的按钮行,复制按钮旁边多了一个铅笔按钮。点击后,消息气泡换成编辑框: + +- 文字可以直接修改。Enter 发送,Shift+Enter 换行,Esc 取消。输入法正在选词时,Enter 和 Esc 交给输入法。 +- 原消息带的图片和文件列在编辑框上方,可以逐个移除。也可以选择、粘贴或拖入新的文件和图片:它们先上传到当前对话,和原生输入框一样先校验,校验失败时还没有创建新对话。文字、附件都删空时不能发送。 +- 消息里引用的其他对话(`@对话`)会保留,编辑框下方显示“引用会话 · …”,和发出的消息一致。改动、删除或覆盖了引用文字时,这个引用就去掉;拿不准的情况一律去掉,不会留下用户已经删掉的引用。 +- 发送后,新对话接替原对话:用户自己改过的标题会带过去,置顶的对话仍然置顶,正在看原对话的会跳到新对话,原对话移进归档。 + +几种情况的处理: + +- 对话生成中,铅笔不可点,生成结束或停止后可以编辑。 +- 回复生成中插入的消息(插话)不能单独编辑:它是在一轮进行到一半时加入的,那时可能已经执行了工具,没法从中间重来。插话下方显示一个不可点的铅笔,提示去编辑这一轮开头的消息;编辑开头那条消息时,编辑框会提示“这一轮还有 N 条插话,发送后会一起替换”。 +- 同一个对话可以同时打开多条消息的编辑框。一条发出后,新对话里仍有的消息,它的草稿(包括还在上传的附件)跟着搬过去;新对话里已经没有的消息,草稿关闭。同一个对话一次只能发一条编辑,其余编辑框提示等待。 +- 编辑框滚出视野或切换到别的对话,草稿和上传都保留。 +- 子代理对话没有铅笔;没有分叉或发送权限时也不显示。 + +插件不会撤销已经执行过的文件修改和外部操作,编辑框里会提示这一点。 + +## 安装 + +在兼容 DSH 客户端的「插件」页添加 `@eduwork/dsh-message-edit`,安装并启用。需要 DSH `>=0.2.2-rc.1 <0.2.3-0`;本版本在官方 `0.2.2-rc.1` Runtime 上验证。 + +## 实现 + +- **主机端**(`src/index.js`、`src/remote.js`):Typert Remote `eduworkMessageEdit/stageAttachments`。它把原对话里存好的附件在主机内重新登记成新对话的上传凭证,附件内容不经过浏览器。和 DSH 自己读附件的规则一样,存储引用按调用方对相应对话的权限处理;这次调用要读原对话、写新对话,所以访问控制两个对话都检查。owner 以外的调用方只有在访问策略明确放行这个方法时才能调用。 +- **客户端**(`src/client/`): + - `index.jsx` 负责分叉、重发、接替原对话;分叉切点沿用 DSH 主机省略切点时的规则(`fork-boundary.js`),切点前继承的排队消息在发送前清掉。 + - `editor.jsx` 是编辑框。 + - `drafts.js` 在 React 外保存草稿和上传,因为 DSH 的对话列表是虚拟滚动,行会卸载。 + - `mentions.js` 跟踪引用其他对话的位置。 + - `steering.jsx` 给插话加不可用的铅笔。 + +依赖 DSH 的几处界面细节: + +- 用户消息的按钮行没有插件扩展位。铅笔通过 `conversation.chat.user.before` 入口找到所在行,再挂进行内的按钮行(`[data-clock]`)。 +- DSH 的插话行不渲染这个入口,所以插话的铅笔由常驻的 `shell.overlay` 组件按 `data-chat-flow-kind="steering"` 找到。 + +DSH 改了这些结构时,铅笔可能不显示,但不会影响对话本身。 + +## 本地开发 + +在本包目录执行,构建和测试使用锁定的 DSH Runtime: + +```sh +npm ci +export EDUWORK_MESSAGE_EDIT_RUNTIME=../../third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime +(cd "$EDUWORK_MESSAGE_EDIT_RUNTIME" && npm ci --ignore-scripts) +npm run check +``` + +浏览器验收用这个 Runtime 启动独立的 DSH 网页版,接入合成模型,用无头 Chromium 走一遍主要场景: + +- 编辑中间的消息; +- 生成中和插话; +- 附件的移除和新增; +- 引用其他对话; +- 多个编辑框; +- 刷新后再检查一遍。 + +它不请求真实模型,DSH 数据、HOME 和默认工作区都放在输出目录里。需要先安装与 Runtime 中 `playwright-core` 版本匹配的 Chromium: + +```sh +node scripts/browser-test.mjs --output <新的空目录> +``` diff --git a/packages/dsh-message-edit/README_EN.md b/packages/dsh-message-edit/README_EN.md new file mode 100644 index 00000000..7661a808 --- /dev/null +++ b/packages/dsh-message-edit/README_EN.md @@ -0,0 +1,73 @@ +# Message editing + +[简体中文](README.md) | **English** + +`@eduwork/dsh-message-edit` lets users change a message they already sent and resend it from there, like editing a message in the Codex App. After the edit is sent, the conversation continues from that message with the new content; the old messages and replies after it are no longer shown. + +A DSH session log is append-only and cannot change in place, so the plugin uses DSH's native fork. It forks a new conversation just before the message, sends the edited message as the new conversation's next turn, and lets the new conversation take the original's place. The original is left unchanged and moved to the archive, where it can be restored. The plugin does not patch the DSH Runtime or UI bundles. + +## Usage + +A pencil button sits next to the copy button under each user message. Clicking it replaces the message bubble with an editor: + +- Edit the text directly. Enter sends, Shift+Enter adds a line, Esc cancels. While an input method is choosing a candidate, Enter and Esc belong to it. +- The message's images and files are listed above the text and can be removed one by one. New files and images can be picked, pasted or dropped in. They upload to the current conversation first and are checked like the native composer's, so a refused file fails before any new conversation exists. With no text and no attachments left, sending is disabled. +- Conversations the message references (`@conversation`) stay referenced. The editor shows "Referenced conversations · …" under the text, matching the sent message. A reference is dropped when its text is changed, deleted or typed over. Whenever that cannot be told for certain the reference is dropped, so a reference the user removed is never sent. +- Once sent, the new conversation takes over: a title the user set is carried over, a pinned conversation stays pinned, anyone viewing the original moves to the new one, and the original is archived. + +Particular cases: + +- While a reply is generating, the pencil is unavailable; it works again once the reply finishes or is stopped. +- A message sent while a reply was generating (a steered message) cannot be edited on its own. It joined the turn partway, possibly after tools had run, and a turn cannot be restarted from its middle. Its row shows an unavailable pencil that points to the message that started the turn. Editing that message warns "This turn has N more message(s) sent mid-reply; sending replaces them too". +- Several messages of one conversation can be edited at once. When one edit is sent, drafts of messages the new conversation still has move to it, uploads in flight included; drafts of messages it no longer has close. One edit of a conversation is sent at a time; the other editors wait. +- Drafts and uploads survive scrolling the editor out of view and switching conversations. +- Subagent conversations get no pencil, nor does a Peer without fork or prompt access. + +The plugin does not undo file changes or other actions already carried out; the editor says so. + +## Installation + +In a compatible DSH client, open the Plugins page, add `@eduwork/dsh-message-edit`, install and enable it. It requires DSH `>=0.2.2-rc.1 <0.2.3-0`; this version is verified on the official `0.2.2-rc.1` Runtime. + +## Implementation + +- **Host** (`src/index.js`, `src/remote.js`): the Typert Remote `eduworkMessageEdit/stageAttachments`. It re-stages attachments stored in the original conversation as upload receipts of the new conversation inside the Host, so their bytes never pass through the browser. As with DSH's own attachment reads, a stored reference is honoured within the caller's access to the conversations involved. The call reads from the original and writes to the new conversation, so access control checks both. Callers other than the owner can call it only when an access policy explicitly allows this method. +- **Client** (`src/client/`): + - `index.jsx` forks, resends and replaces the original. The cut follows the rule the DSH Host applies when no cut is given (`fork-boundary.js`), and input queued before the cut is cleared before the send. + - `editor.jsx` is the editor. + - `drafts.js` keeps drafts and uploads outside React, because DSH's chat list is virtualized and unmounts rows. + - `mentions.js` follows the references to other conversations. + - `steering.jsx` adds the unavailable pencil to steered messages. + +A few DSH interface details are relied on: + +- User message action rows have no plugin slot. The pencil finds its row through the `conversation.chat.user.before` seat and is placed into the row's action bar (`[data-clock]`). +- DSH does not render that seat on steered rows, so their pencil comes from an always-mounted `shell.overlay` component that finds `data-chat-flow-kind="steering"` rows. + +If DSH changes these structures the pencil may not show, but conversations themselves are unaffected. + +## Development + +Run in this package directory. Building and testing use the locked DSH Runtime: + +```sh +npm ci +export EDUWORK_MESSAGE_EDIT_RUNTIME=../../third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime +(cd "$EDUWORK_MESSAGE_EDIT_RUNTIME" && npm ci --ignore-scripts) +npm run check +``` + +The browser acceptance starts a separate DSH web app from that Runtime with a synthetic model and runs the main scenarios in headless Chromium: + +- editing a middle message; +- generating and steered messages; +- removing and adding attachments; +- referencing other conversations; +- several editors; +- a reload afterwards. + +It makes no live model requests and keeps the DSH data, HOME and default workspace inside its output directory. It needs a Chromium matching the Runtime's `playwright-core`: + +```sh +node scripts/browser-test.mjs --output +``` diff --git a/packages/dsh-message-edit/cordis.patch.yml b/packages/dsh-message-edit/cordis.patch.yml new file mode 100644 index 00000000..2c21b032 --- /dev/null +++ b/packages/dsh-message-edit/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: eduwork-message-edit + name: '@eduwork/dsh-message-edit' diff --git a/packages/dsh-message-edit/package-lock.json b/packages/dsh-message-edit/package-lock.json new file mode 100644 index 00000000..38f3fe0c --- /dev/null +++ b/packages/dsh-message-edit/package-lock.json @@ -0,0 +1,527 @@ +{ + "name": "@eduwork/dsh-message-edit", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@eduwork/dsh-message-edit", + "version": "0.1.0", + "license": "MulanPSL-2.0", + "devDependencies": { + "esbuild": "0.28.1" + }, + "engines": { + "node": ">=24.22.1" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-ui-primitives": ">=0.2.2-rc.1 <0.2.3-0", + "@deepseek-ai/dsh-typert-protocol": ">=0.2.2-rc.1 <0.2.3-0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zod": "^4.4.3" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-client-ui-primitives": { + "optional": true + }, + "@deepseek-ai/dsh-typert-protocol": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + } + } +} diff --git a/packages/dsh-message-edit/package.json b/packages/dsh-message-edit/package.json new file mode 100644 index 00000000..8e482125 --- /dev/null +++ b/packages/dsh-message-edit/package.json @@ -0,0 +1,63 @@ +{ + "name": "@eduwork/dsh-message-edit", + "version": "0.1.0", + "description": "Edit a sent user message and resend it, replacing the conversation with a native DSH fork", + "type": "module", + "main": "lib/index.js", + "exports": { + ".": "./lib/index.js", + "./client": "./lib/client.js", + "./typert": "./lib/typert.host.js", + "./package.json": "./package.json" + }, + "files": ["lib", "cordis.patch.yml", "README.md", "README_EN.md", "LICENSE", "CHANGELOG.md"], + "scripts": { + "build": "node scripts/build.mjs", + "test": "node --import ./test/runtime-resolver.mjs --test test/*.test.mjs", + "check": "npm run build && npm test" + }, + "engines": { "node": ">=24.22.1" }, + "devDependencies": { + "esbuild": "0.28.1" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-file-upload", + "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-workspace" + ], + "platform": "web" + }, + "bundle": { "patch": "./cordis.patch.yml" } + }, + "peerDependencies": { + "@deepseek-ai/dsh-typert-protocol": ">=0.2.2-rc.1 <0.2.3-0", + "@deepseek-ai/dsh-client-ui-primitives": ">=0.2.2-rc.1 <0.2.3-0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zod": "^4.4.3" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-typert-protocol": { "optional": true }, + "@deepseek-ai/dsh-client-ui-primitives": { "optional": true }, + "react": { "optional": true }, + "react-dom": { "optional": true }, + "zod": { "optional": true } + }, + "repository": { "type": "git", "url": "git+https://github.com/ECNU/EduWork.git", "directory": "packages/dsh-message-edit" }, + "homepage": "https://github.com/ECNU/EduWork/tree/main/packages/dsh-message-edit#readme", + "bugs": { "url": "https://github.com/ECNU/EduWork/issues" }, + "keywords": ["dsh", "deepseek-harness", "message-edit", "eduwork"], + "publishConfig": { "access": "public" }, + "allowScripts": { "esbuild@0.28.1": true }, + "license": "MulanPSL-2.0" +} diff --git a/packages/dsh-message-edit/scripts/browser-test.mjs b/packages/dsh-message-edit/scripts/browser-test.mjs new file mode 100644 index 00000000..2218af6f --- /dev/null +++ b/packages/dsh-message-edit/scripts/browser-test.mjs @@ -0,0 +1,293 @@ +// Browser acceptance against the locked DSH Runtime: a private DSH home with +// this package linked in, a synthetic model, and headless Chromium. Every +// reply lists the user texts the model saw, so each check reads what a resend +// carried. Not part of `npm run check`: it needs a Playwright Chromium. +// EDUWORK_MESSAGE_EDIT_RUNTIME= node scripts/browser-test.mjs --output +import assert from 'node:assert/strict' +import { spawn, execFileSync } from 'node:child_process' +import { once } from 'node:events' +import { createServer } from 'node:http' +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +const { values } = parseArgs({ options: { output: { type: 'string' } } }) +if (!values.output || !process.env.EDUWORK_MESSAGE_EDIT_RUNTIME) throw Error('Set EDUWORK_MESSAGE_EDIT_RUNTIME and pass --output ') +const runtime = resolve(process.env.EDUWORK_MESSAGE_EDIT_RUNTIME) +const output = resolve(values.output) +const plugin = fileURLToPath(new URL('../', import.meta.url)) +await mkdir(output) +const home = join(output, 'dsh-home'), osHome = join(output, 'os-home') +await mkdir(osHome) +const { chromium } = createRequire(join(runtime, 'package.json'))('playwright-core') +const dsh = join(runtime, 'node_modules/.bin/dsh') +const result = { schemaVersion: 1, kind: 'message-edit-browser-acceptance', synthetic: true, externalModelRequests: 0, checks: [], consoleErrors: [], passed: false } + +// A Messages-compatible model: the reply names every user text starting with +// "Q", the images and files of the last user message, and how many referenced +// conversation snapshots the request carried. "SLOW" holds the reply. +const blocks = message => (typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content) +const model = createServer(async (request, response) => { + let raw = '' + for await (const chunk of request) raw += chunk + const path = new URL(request.url, 'http://model').pathname + if (request.method === 'GET' && path.endsWith('/models')) return response.end(JSON.stringify({ object: 'list', data: [{ id: 'deepseek-flash', object: 'model' }] })) + if (!path.endsWith('/messages')) return response.writeHead(404).end() + const users = (JSON.parse(raw).messages ?? []).filter(message => message.role === 'user') + const texts = users.flatMap(message => blocks(message).filter(block => block.type === 'text').map(block => block.text)) + const last = blocks(users.at(-1) ?? { content: [] }) + const lastText = last.filter(block => block.type === 'text').map(block => block.text) + if (lastText.join('').includes('SLOW')) await new Promise(settle => setTimeout(settle, 4000)) + const files = lastText.map(text => text.match(/^\[File "([^"]+)"/)?.[1]).filter(Boolean) + const snapshots = texts.filter(text => text.includes('Referenced sessions')).length + const text = `模型看到:${texts.filter(text => text.startsWith('Q')).join(' | ') || '(无)'};最后一条带图 ${last.filter(block => block.type === 'image').length} 张` + + `${files.length ? `、文件 ${files.join('+')}` : ''}${snapshots ? `、引用快照 ${snapshots}` : ''}` + response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }) + for (const [event, data] of [ + ['message_start', { type: 'message_start', message: { id: `msg_${Date.now()}`, type: 'message', role: 'assistant', model: 'fixture', content: [], stop_reason: null, usage: { input_tokens: 10, output_tokens: 0 } } }], + ['content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }], + ['content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }], + ['content_block_stop', { type: 'content_block_stop', index: 0 }], + ['message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 5 } }], + ['message_stop', { type: 'message_stop' }], + ]) response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + response.end() +}) +// Everything started here is stopped in the `finally` below, whatever fails. +let host, browser, page, url +async function start() { + model.listen(0, '127.0.0.1') + await once(model, 'listening') + const environment = { + ...process.env, HOME: osHome, USERPROFILE: osHome, DSH_HOME: home, NO_PROXY: '127.0.0.1,localhost', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${model.address().port}/v1`, DEEPSEEK_API_KEY: 'synthetic', + } + execFileSync(dsh, ['plugin', '--profile', 'web', 'add', `link:${plugin}`], { env: environment, stdio: 'ignore' }) + // The first-use workspace lives under the account's Documents folder, found + // through the OS rather than HOME; keep it inside the output as well. + await writeFile(join(home, 'profiles/web/cordis.patch.yml'), `- id: workspace-controller\n config:\n documentsDirectory: ${JSON.stringify(join(output, 'documents'))}\n`) + const port = await new Promise(settle => { const probe = createServer().listen(0, '127.0.0.1', () => { const { port } = probe.address(); probe.close(() => settle(port)) }) }) + host = spawn(dsh, ['web', '--no-open', '--port', String(port)], { env: environment, stdio: ['ignore', 'pipe', 'pipe'] }) + let log = '' + url = await new Promise((settle, fail) => { + const read = chunk => { log += chunk; const found = log.match(/http:\/\/\S+/)?.[0]; if (found) settle(found) } + host.stdout.on('data', read); host.stderr.on('data', read) + host.once('exit', code => fail(Error(`dsh web exited with ${code}: ${log.replace(/token=\S+/g, 'token=[redacted]').slice(-1600)}`))) + }) + browser = await chromium.launch({ headless: true }) + page = await browser.newPage({ viewport: { width: 1280, height: 860 }, locale: 'zh-CN' }) + page.on('console', message => { if (message.type() === 'error') result.consoleErrors.push(message.text().slice(0, 400)) }) + page.on('pageerror', error => result.consoleErrors.push(`pageerror: ${error.message}`)) +} + +const check = (name, ok, detail = '') => { + result.checks.push({ name, passed: Boolean(ok), ...detail === '' ? {} : { detail: String(detail) } }) + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail === '' ? '' : ` — ${detail}`}`) +} + +const composer = () => page.locator('[contenteditable="true"]').first() +const editors = () => page.locator('.eduwork-message-editor') +const replyCount = () => page.evaluate(() => [...document.querySelectorAll('*')].filter(node => node.childElementCount === 0 && node.textContent.startsWith('模型看到:')).length) +const reply = text => page.getByText(text).first().waitFor({ timeout: 30_000 }) +const lastReply = () => page.getByText(/^模型看到:/).last().innerText() +const current = () => page.locator('[role="treeitem"][aria-selected="true"]').getAttribute('data-row-key') +// The workspace registry's own record of archived conversations, and the logs on disk. +const archivedIds = async () => JSON.parse(await readFile(join(home, 'storages/workspace.json'), 'utf8')).global.archivedSessionIds +const sessionCount = async () => (await readdir(join(home, 'sessions'), { recursive: true })).filter(path => /session-[^/]+$/.test(path)).length +const row = text => page.locator('[data-chat-node-key]').filter({ has: page.locator('[data-eduwork-message-edit]') }).filter({ hasText: text }).first() +const caretTo = (field, at) => field.evaluate((node, at) => { const offset = at === 'end' ? node.value.length : typeof at === 'number' ? at : node.value.indexOf(at); node.setSelectionRange(offset, offset) }, at) +const select = (field, needle) => field.evaluate((node, needle) => { const at = node.value.indexOf(needle); node.setSelectionRange(at, at + needle.length) }, needle) + +async function newSession() { + await page.getByText(/^(新会话|New Session)$/).first().click() + await page.waitForTimeout(800) +} +async function send(text, { wait = true, steer = false } = {}) { + const before = await replyCount() + await composer().click() + await page.keyboard.type(text) + await page.keyboard.press(steer ? (process.platform === 'darwin' ? 'Meta+Enter' : 'Control+Enter') : 'Enter') + if (wait) await page.waitForFunction(count => [...document.querySelectorAll('*')].filter(node => node.childElementCount === 0 && node.textContent.startsWith('模型看到:')).length > count, before, { timeout: 30_000 }) + await page.waitForTimeout(600) +} +async function open(text) { + const target = row(text) + await target.scrollIntoViewIfNeeded() + await target.hover() + await target.locator('[data-eduwork-message-edit]').click() + return target +} +// Replace the "Qn" tag that starts the text, keys at the caret like a user. +async function retag(field, tag) { + await caretTo(field, 0) + await field.press('Delete') + await field.press('Delete') + await field.pressSequentially(tag) +} +async function sendEdit(target, expected) { + const before = await current() + await target.locator('.eduwork-message-editor button[data-primary]').click() + await reply(expected) + await page.waitForFunction(previous => document.querySelector('[role="treeitem"][aria-selected="true"]')?.getAttribute('data-row-key') !== previous, before, { timeout: 20_000 }) + await page.waitForTimeout(1200) +} +const transcript = async () => (await page.locator('[data-chat-node-key]').allInnerTexts()).join('\n') + +try { + await start() + await page.goto(url) + await page.waitForTimeout(3000) + for (const name of [/^(继续|Continue)$/, /^(我知道了|Got it)$/]) { + const button = page.getByRole('button', { name }).first() + if (await button.isVisible().catch(() => false)) await button.click() + } + + // Editing a middle message replaces the conversation with a branch cut before it. + await newSession() + for (const text of ['Q1 第一个问题', 'Q2 原来的第二个问题', 'Q3 第三个问题']) await send(text) + const original = await current() + let target = row('Q2 原来的第二个问题') + await target.hover() + check('pencil follows the copy button', JSON.stringify(await target.locator('[data-clock] button').evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label')))) === '["复制","编辑消息"]') + await target.locator('[data-eduwork-message-edit]').click() + const field = target.locator('textarea') + check('editor holds the text with the caret after it', await field.inputValue() === 'Q2 原来的第二个问题' && await field.evaluate(node => node.selectionStart === node.value.length && document.activeElement === node)) + await field.press('Escape') + check('Escape restores the message', await editors().count() === 0 && await target.locator('[data-message-bubble]').isVisible()) + target = await open('Q2 原来的第二个问题') + await retag(target.locator('textarea'), 'Q2') + await caretTo(target.locator('textarea'), 'end') + await target.locator('textarea').pressSequentially(' 改过') + await sendEdit(target, '模型看到:Q1 第一个问题 | Q2 原来的第二个问题 改过;') + const text = await transcript() + check('branch keeps the history before the edit and nothing after it', text.includes('Q1 第一个问题') && !text.includes('Q3 第三个问题')) + const originalId = original.slice('session:'.length) + check('the original conversation is archived, its log kept', await page.locator(`[data-row-key="${original}"]`).count() === 0 + && (await archivedIds()).includes(originalId) && (await readdir(join(home, 'sessions'), { recursive: true })).some(path => path.endsWith(originalId))) + + // While a Turn runs the pencil waits; a steered message goes with its Turn. + await newSession() + await send('Q1 SLOW 被插话的问题', { wait: false }) + check('pencil is unavailable while the reply runs', await row('Q1 SLOW').locator('[data-eduwork-message-edit]').getAttribute('aria-disabled') === 'true') + await send('Q2 插话', { wait: false, steer: true }) + await reply(/^模型看到:Q1 SLOW 被插话的问题 \| Q2 插话/) + await page.waitForTimeout(1500) + const steering = page.locator('[data-chat-flow-kind="steering"]').filter({ hasText: 'Q2 插话' }) + const steeringPencil = steering.locator('[data-eduwork-steering-edit]') + check('a steered message shows an unavailable pencil', await steeringPencil.getAttribute('aria-disabled') === 'true' && await steeringPencil.getAttribute('title') === '插话不能单独编辑,请编辑这一轮开头的消息') + await steering.hover() + await steeringPencil.click({ force: true }) + check('clicking it opens no editor', await editors().count() === 0) + target = await open('Q1 SLOW 被插话的问题') + check('the Turn message editor says the steered message goes too', await target.locator('.eduwork-message-editor-steers').innerText() === '这一轮还有 1 条插话,发送后会一起替换') + await retag(target.locator('textarea'), 'Q3') + await sendEdit(target, /^模型看到:Q3 SLOW 被插话的问题;/) + check('the steered message is replaced with its Turn', !(await transcript()).includes('Q2 插话')) + + // Stored attachments carry over, removable; new ones upload while editing. + await newSession() + await page.locator('input[type="file"]:not(.eduwork-message-editor *)').first().setInputFiles([ + { name: 'red.png', mimeType: 'image/png', buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64') }, + { name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('synthetic notes') }, + ]) + await page.waitForTimeout(1500) + await send('Q1 带图和文件') + check('the original Turn carried both', (await lastReply()).includes('最后一条带图 1 张、文件 notes.txt')) + target = await open('Q1 带图和文件') + const chips = () => target.locator('.eduwork-message-editor-attachment').evaluateAll(nodes => nodes.map(node => `${node.dataset.kind}:${node.dataset.state}:${node.title}`)) + check('editor lists the stored image and file', JSON.stringify(await chips()) === '["image:ready:red.png","file:ready:notes.txt"]', JSON.stringify(await chips())) + await target.locator('.eduwork-message-editor-attachment[data-kind="image"]').hover() + await target.locator('.eduwork-message-editor-attachment[data-kind="image"] .eduwork-message-editor-remove').click() + await target.locator('.eduwork-message-editor input[type="file"]').setInputFiles({ name: 'added.txt', mimeType: 'text/plain', buffer: Buffer.from('added while editing') }) + await page.locator('.eduwork-message-editor-attachment[data-state="ready"][title="added.txt"]').waitFor({ timeout: 15_000 }) + await retag(target.locator('textarea'), 'Q2') + await sendEdit(target, '模型看到:Q2 带图和文件;最后一条带图 0 张、文件 notes.txt+added.txt') + check('the resend drops the removed image and carries the stored and added files', true) + + // A referenced conversation stays referenced while its mention is left alone. + await newSession() + await composer().click() + await page.keyboard.type('Q1 看看 @') + await page.waitForTimeout(1200) + const label = (await page.locator('[role="option"]').first().innerText()).split('\n')[0] + await page.keyboard.press('Enter') + await page.waitForTimeout(400) + await page.keyboard.type(' 这个') + const before = await replyCount() + await page.keyboard.press('Enter') + await page.waitForFunction(count => [...document.querySelectorAll('*')].filter(node => node.childElementCount === 0 && node.textContent.startsWith('模型看到:')).length > count, before, { timeout: 30_000 }) + check('the original Turn carried the snapshot', (await lastReply()).includes('引用快照 1')) + target = await open('Q1 看看') + check('editor names the referenced conversation', await target.locator('.eduwork-message-editor-references').innerText() === `引用会话 · ${label}`) + await retag(target.locator('textarea'), 'Q2') + await sendEdit(target, /^模型看到:Q2 看看 .*引用快照 1$/) + check('the edited message is sent with the reference', true) + target = await open('Q2 看看') + await select(target.locator('textarea'), `@${label}`) + await target.locator('textarea').press('Backspace') + check('deleting the mention removes the reference line', await target.locator('.eduwork-message-editor-references').count() === 0) + await retag(target.locator('textarea'), 'Q3') + await sendEdit(target, /^模型看到:Q3 看看/) + check('a deleted mention sends no snapshot', !(await lastReply()).includes('引用快照')) + + // Other open edits of a replaced conversation: earlier ones move along, later ones close. + await newSession() + for (const text of ['Q1 甲', 'Q2 乙', 'Q3 丙']) await send(text) + await open('Q1 甲') + await caretTo(editors().first().locator('textarea'), 'end') + await editors().first().locator('textarea').pressSequentially(' 草稿') + await open('Q3 丙') + target = await open('Q2 乙') + check('three editors open at once', await editors().count() === 3) + const replaced = await current() + const during = await target.evaluate(async node => { + node.querySelector('.eduwork-message-editor button[data-primary]').click() + await new Promise(settle => requestAnimationFrame(() => setTimeout(settle, 0))) + const first = document.querySelector('.eduwork-message-editor') + return { disabled: first.querySelector('button[data-primary]').disabled, note: first.querySelector('.eduwork-message-editor-note').textContent } + }) + check('other editors wait while one sends', during.disabled && during.note.includes('另一条编辑正在发送'), JSON.stringify(during)) + await page.waitForFunction(previous => document.querySelector('[role="treeitem"][aria-selected="true"]')?.getAttribute('data-row-key') !== previous, replaced, { timeout: 20_000 }) + await page.waitForTimeout(2500) + check('the earlier draft moves to the replacement; the later one closes', await editors().count() === 1 && await editors().locator('textarea').inputValue() === 'Q1 甲 草稿') + await editors().locator('textarea').press('Escape') + + // Two sends in the same tick replace the conversation once. + await newSession() + for (const text of ['Q1 戊', 'Q2 己']) await send(text) + const sessions = await sessionCount() + await open('Q1 戊') + await open('Q2 己') + await page.evaluate(() => { for (const button of document.querySelectorAll('.eduwork-message-editor button[data-primary]')) button.click() }) + await page.waitForTimeout(4000) + check('two sends in one tick create one branch', await sessionCount() === sessions + 1, `${sessions} -> ${await sessionCount()}`) + + // After a reload every user message has its pencil again. + await page.reload() + await page.locator('[role="treeitem"][data-row-key^="session:"]').first().click() + await page.locator('[data-chat-flow-kind="user"]').first().waitFor({ timeout: 15_000 }) + await page.waitForTimeout(1500) + const pencils = await page.locator('button[data-eduwork-message-edit]').count() + check('every user message has one pencil after a reload', pencils > 0 && pencils === await page.locator('[data-chat-flow-kind="user"]').count()) + await page.screenshot({ path: join(output, 'final.png') }) +} catch (error) { + const notes = await page?.locator('.eduwork-message-editor-note').allInnerTexts().catch(() => []) ?? [] + check('scenario completed', false, `${error.message.split('\n')[0]}${notes.length ? ` (editor: ${notes.join(' / ')})` : ''}`) + await page?.screenshot({ path: join(output, 'failure.png') }).catch(() => {}) +} finally { + await browser?.close().catch(() => {}) + // A Host that already exited, by code or by signal, has nothing left to stop. + if (host?.exitCode === null && host.signalCode === null) { + host.kill() + await once(host, 'exit') + } + model.closeAllConnections() + model.close() + result.passed = result.checks.every(entry => entry.passed) && result.consoleErrors.length === 0 + await writeFile(join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n') + console.log(`${result.checks.filter(entry => entry.passed).length}/${result.checks.length} checks passed; console errors: ${result.consoleErrors.length}`) + if (result.consoleErrors.length) console.log(result.consoleErrors.join('\n')) + assert.ok(result.passed, 'Browser acceptance failed') +} diff --git a/packages/dsh-message-edit/scripts/build.mjs b/packages/dsh-message-edit/scripts/build.mjs new file mode 100644 index 00000000..3b2cbcc0 --- /dev/null +++ b/packages/dsh-message-edit/scripts/build.mjs @@ -0,0 +1,19 @@ +import { build } from 'esbuild' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +if (!process.env.EDUWORK_MESSAGE_EDIT_RUNTIME) throw Error('Set EDUWORK_MESSAGE_EDIT_RUNTIME to the locked DSH Runtime directory') +const root = fileURLToPath(new URL('../', import.meta.url)) +const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) +// DSH packages come from the locked Runtime: the Host provides them at run +// time, and the client bundle carries only the Remote descriptor helper. +const nodePaths = [join(resolve(process.env.EDUWORK_MESSAGE_EDIT_RUNTIME), 'node_modules')] +await build({ absWorkingDir: root, entryPoints: ['src/index.js', 'src/typert.host.js'], outdir: 'lib', + bundle: true, platform: 'node', format: 'esm', target: 'node24', packages: 'external' }) +// The page shares React, the UI primitives, and zod between plugins. +await build({ absWorkingDir: root, entryPoints: ['src/client/index.jsx'], outfile: 'lib/client.js', nodePaths, + bundle: true, platform: 'browser', format: 'cjs', target: 'es2022', minify: true, external: ['react', 'react/*', 'react-dom', 'react-dom/*', '@deepseek-ai/dsh-client-ui-primitives', 'zod'], + banner: { js: `window.__ModuleLoader__.load({ id: ${JSON.stringify(manifest.name)}, factory: (require) => { var module = { exports: {} }; var exports = module.exports;` }, + footer: { js: 'return module.exports; } });' }, +}) diff --git a/packages/dsh-message-edit/src/client/drafts.js b/packages/dsh-message-edit/src/client/drafts.js new file mode 100644 index 00000000..79d0f1aa --- /dev/null +++ b/packages/dsh-message-edit/src/client/drafts.js @@ -0,0 +1,208 @@ +import { useSyncExternalStore } from 'react' +import { followEdit } from './mentions.js' + +const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] +// The native composer's default upload concurrency. +const CONCURRENT_UPLOADS = 2 + +/** + * Open message edits, kept outside React: the chat list virtualizes its rows, + * so a row scrolled out of view unmounts, and the user may switch Sessions and + * come back. A draft, its uploads, and a send in flight survive both. + * + * Files added while editing upload to the Session being edited as soon as they + * are added, exactly as the native composer uploads its drafts. The Host thus + * validates them before any branch exists, and sending re-stages the stored + * attachments into the branch like the message's original ones. + */ +export class EditDrafts { + #drafts = new Map() + #listeners = new Set() + #queue = [] + #active = 0 + #nextId = 0 + + constructor(fileUpload) { + this.fileUpload = fileUpload + } + + subscribe = listener => { + this.#listeners.add(listener) + return () => this.#listeners.delete(listener) + } + + get = key => this.#drafts.get(key) + + /** + * Whether an edit of the Session is being sent. A sent edit replaces the + * Session, so its other edits wait for that rather than branch from a + * conversation about to be archived. + */ + sendingIn = sessionId => [...this.#drafts].some(([key, draft]) => draft.sending && key.startsWith(`${sessionId}\u0000`)) + + /** Claim the Session's one send for this draft; false when another holds it. */ + startSending(key, sessionId) { + if (!this.#editable(key) || this.sendingIn(sessionId)) return false + this.update(key, () => ({ sending: true, notice: null })) + return true + } + + /** + * Open a draft of the message at `seq`; `mentions` are the referenced + * conversations its text cites. + */ + open(key, seq, text, mentions) { + this.#set(key, { seq, text, mentions, removed: new Set(), added: [], sending: false, notice: null }) + } + + /** Close a draft, cancelling its uploads and releasing its previews. */ + close(key) { + for (const item of this.#drafts.get(key)?.added ?? []) { + item.controller?.abort() + if (item.previewUrl) URL.revokeObjectURL(item.previewUrl) + } + this.#set(key, undefined) + } + + /** Drop every draft when the plugin goes away. */ + dispose() { + this.#queue.length = 0 + for (const key of [...this.#drafts.keys()]) this.close(key) + this.#listeners.clear() + } + + /** + * Hand a replaced Session's drafts to its replacement. A draft of a message + * before `seq` moves along, uploads and all, since the replacement inherited + * that message under the same id; the rest edit messages the replacement + * lacks and close. The draft being sent closes on its own. + */ + moveSession(from, to, seq) { + for (const [key, draft] of [...this.#drafts]) { + if (!key.startsWith(`${from}\u0000`) || draft.sending) continue + if (draft.seq >= seq) { + this.close(key) + continue + } + this.#drafts.delete(key) + this.#set(`${to}${key.slice(from.length)}`, draft) + } + } + + update(key, change) { + const draft = this.#drafts.get(key) + if (draft) this.#set(key, { ...draft, ...change(draft) }) + } + + /** Take the textarea's new value; `edit` is the input the textarea announced for it. */ + setText(key, text, edit) { + this.update(key, draft => ({ text, mentions: followEdit(draft.mentions, draft.text, text, edit), notice: null })) + } + + // While a send is in flight its content is fixed: the edits below wait. + #editable = key => this.#drafts.get(key)?.sending === false + + removeOriginal(key, index) { + if (!this.#editable(key)) return + this.update(key, draft => ({ removed: new Set(draft.removed).add(index), notice: null })) + } + + removeAdded(key, id) { + const item = this.#editable(key) && this.#drafts.get(key).added.find(candidate => candidate.id === id) + if (!item) return + item.controller?.abort() + if (item.previewUrl) URL.revokeObjectURL(item.previewUrl) + this.update(key, draft => ({ added: draft.added.filter(candidate => candidate !== item), notice: null })) + } + + /** + * Admit one batch of picked, pasted, or dropped files with the native + * composer's checks, then start uploading them. + * @returns a refusal as `[copyKey, values]`, or null when the batch was added. + */ + addFiles(key, sessionId, files, { directories, limits, keptImageBytes }) { + const draft = this.#drafts.get(key) + if (!this.#editable(key) || files.length === 0) return null + if (files.some(file => directories?.has(file))) return ['folder', {}] + const mediaTypes = limits?.mediaTypes ?? IMAGE_TYPES + const images = files.filter(file => mediaTypes.includes(file.type)) + if (limits) { + const sizes = [...keptImageBytes, ...draft.added.filter(item => item.kind === 'image').map(item => item.file.size), ...images.map(file => file.size)] + if (sizes.length > limits.maxImagesPerMessage) return ['tooMany', { count: limits.maxImagesPerMessage }] + if (images.some(file => file.size > limits.maxImageBytes)) return ['imageTooLarge', { bytes: limits.maxImageBytes }] + if (sizes.reduce((sum, size) => sum + size, 0) > limits.maxMessageImageBytes) return ['imagesTooLarge', { bytes: limits.maxMessageImageBytes }] + } + const added = files.map(file => { + const image = images.includes(file) + return { id: `added-${this.#nextId++}`, kind: image ? 'image' : 'file', file, previewUrl: image ? URL.createObjectURL(file) : undefined } + }) + this.update(key, current => ({ added: [...current.added, ...added], notice: null })) + for (const item of added) this.#upload(key, sessionId, item.id) + return null + } + + retry(key, sessionId, id) { + if (!this.#editable(key)) return + this.update(key, () => ({ notice: null })) + this.#upload(key, sessionId, id) + } + + #set(key, draft) { + if (draft === undefined) this.#drafts.delete(key) + else this.#drafts.set(key, draft) + for (const listener of this.#listeners) listener() + } + + #upload(key, sessionId, id) { + const controller = new AbortController() + // Every later write is dropped once this attempt is no longer the item's + // current one: removed, retried, or its draft closed. The draft is found + // by the attempt, as it may have moved to a replacing Session meanwhile. + const patch = upload => { + const current = item => item.id === id && item.controller === controller + const [draftKey] = [...this.#drafts].find(([, draft]) => draft.added.some(current)) ?? [] + if (draftKey !== undefined) this.update(draftKey, draft => ({ added: draft.added.map(item => (current(item) ? { ...item, upload } : item)) })) + } + this.update(key, draft => ({ + added: draft.added.map(item => (item.id === id ? { ...item, controller, upload: { status: 'uploading', loaded: 0 } } : item)), + })) + const item = this.#drafts.get(key)?.added.find(candidate => candidate.id === id) + if (!item) return + const name = item.file.name || undefined + const onProgress = ({ loaded, total }) => patch({ status: 'uploading', loaded, total }) + this.#queue.push(async () => { + if (controller.signal.aborted) return + try { + const result = item.kind === 'image' + ? await this.fileUpload.uploadImage(sessionId, item.file, item.file.type, name, controller.signal, onProgress) + : await this.fileUpload.upload(sessionId, item.file, name, controller.signal, onProgress) + if (controller.signal.aborted) return + if (result.ok) patch({ status: 'ready', ref: result.value.image ?? result.value.file }) + else patch({ status: 'error', message: result.error.message, reason: result.error.code === 'session/attachment-invalid' ? result.error.details?.reason : undefined }) + } catch (cause) { + if (!controller.signal.aborted) patch({ status: 'error', message: cause instanceof Error ? cause.message : String(cause) }) + } + }) + this.#pump() + } + + #pump() { + while (this.#active < CONCURRENT_UPLOADS && this.#queue.length > 0) { + this.#active++ + void this.#queue.shift()().finally(() => { + this.#active-- + this.#pump() + }) + } + } +} + +/** Subscribe a component to whether another edit of its Session is being sent. */ +export function useSendingIn(drafts, sessionId) { + return useSyncExternalStore(drafts.subscribe, () => drafts.sendingIn(sessionId)) +} + +/** Subscribe a component to one draft, or to its absence. */ +export function useDraft(drafts, key) { + return useSyncExternalStore(drafts.subscribe, () => (key === undefined ? undefined : drafts.get(key))) +} diff --git a/packages/dsh-message-edit/src/client/editor.jsx b/packages/dsh-message-edit/src/client/editor.jsx new file mode 100644 index 00000000..cfe76214 --- /dev/null +++ b/packages/dsh-message-edit/src/client/editor.jsx @@ -0,0 +1,299 @@ +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { FileTypeIcon, IconCloseFillRegular, IconEditOutlineRegular, IconPaperclipOutlineRegular, IconWarningOutlineRegular, fileExtension, fileSizeText } from '@deepseek-ai/dsh-client-ui-primitives' +import { useDraft, useSendingIn } from './drafts.js' +import { locateMentions } from './mentions.js' + +const h = React.createElement + +const isAttachment = block => block.type === 'image' || block.type === 'file' + +/** An image the stored message carries, loaded through the Session for display only. */ +function StoredImage({ attachment, readImage }) { + const [url, setUrl] = useState(null) + useEffect(() => { + let current = null + let live = true + void readImage(attachment).then(read => { + if (!live || !read.ok) return + current = URL.createObjectURL(new Blob([read.value.data], { type: attachment.mediaType })) + setUrl(current) + }) + return () => { live = false; if (current) URL.revokeObjectURL(current) } + }, [attachment, readImage]) + return url && h('img', { src: url, alt: attachment.name ?? '' }) +} + +/** + * One attachment in the editor, drawn like the native draft rail: a 64px image + * square or a 240px file card. `state` is ready, uploading (with `progress` + * in 0–1 when known), failed (retryable), or refused (only removable). + */ +function Chip({ kind, name, bytes, image, state, progress, problem, labels, disabled, onRemove, onRetry }) { + const failed = state === 'failed' || state === 'refused' + const progressBar = state === 'uploading' && h('span', { className: 'eduwork-message-editor-progress', 'aria-hidden': true }, + h('span', { style: progress === undefined ? undefined : { width: `${Math.round(progress * 100)}%`, animation: 'none' } })) + const remove = h('button', { type: 'button', className: 'eduwork-message-editor-remove', 'aria-label': labels.remove, onClick: onRemove, disabled }, + h(IconCloseFillRegular, { size: 12 })) + const retry = state === 'failed' && !disabled ? { role: 'button', tabIndex: 0, 'aria-label': labels.retry, onClick: onRetry, onKeyDown: event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onRetry() } } } : {} + const attributes = { className: 'eduwork-message-editor-attachment', 'data-kind': kind, 'data-state': state, title: failed ? problem : name } + if (kind === 'image') return h('div', attributes, h('div', { className: 'eduwork-message-editor-image', ...retry }, failed ? h(IconWarningOutlineRegular, { size: 20 }) : image), progressBar, remove) + const meta = state === 'uploading' ? labels.uploading : failed ? problem : [fileExtension(name).toUpperCase().slice(0, 8), fileSizeText(bytes)].filter(Boolean).join(' ') + return h('div', attributes, + h('span', { className: 'eduwork-message-editor-file-icon', 'aria-hidden': true }, + state === 'uploading' ? h('span', { className: 'eduwork-message-editor-spinner' }) : h(FileTypeIcon, { path: name })), + h('span', { className: 'eduwork-message-editor-file-body', ...retry }, + h('span', { className: 'eduwork-message-editor-file-name' }, name), + h('span', { className: 'eduwork-message-editor-file-meta' }, meta)), + progressBar, remove) +} + +/** Text for a Host attachment refusal, matching the native composer's wording. */ +export function refusalText(t, reason, limits) { + switch (reason) { + case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('modelUnsupported') + case 'ATTACHMENT_NOT_FOUND': return t('attachmentMissing') + case 'IMAGE_TOO_MANY_PIXELS': return t('tooManyPixels') + case 'IMAGE_DIMENSION_TOO_LARGE': return limits ? t('dimensionTooLarge', { size: limits.maxImageDimension }) : t('attachmentRefused', { reason }) + case 'INVALID_IMAGE': + case 'IMAGE_TYPE_MISMATCH': return t('unsupportedType') + case 'TOO_MANY_IMAGES': return limits ? t('tooMany', { count: limits.maxImagesPerMessage }) : t('attachmentRefused', { reason }) + case 'IMAGE_TOO_LARGE': return limits ? t('imageTooLarge', { size: fileSizeText(limits.maxImageBytes) }) : t('attachmentRefused', { reason }) + case 'IMAGES_TOO_LARGE': return limits ? t('imagesTooLarge', { size: fileSizeText(limits.maxMessageImageBytes) }) : t('attachmentRefused', { reason }) + default: return t('attachmentRefused', { reason }) + } +} + +/** Files carried by a paste or drop, with the members that are directories. */ +function transferFiles(items) { + const files = [] + const directories = new Set() + for (const item of items) { + if (item.kind !== 'file') continue + const file = item.getAsFile() + if (file === null) continue + files.push(file) + if (item.webkitGetAsEntry?.()?.isDirectory === true) directories.add(file) + } + return { files, directories } +} + +const carriesFiles = event => event.dataTransfer?.types.includes('Files') === true + +export function MessageEditSeat({ useChat, useSession, useAccess, useProjection, sessionId, drafts, resend, readImage, referencesOf, isSubagent, composing, t }) { + const anchor = useRef(null) + const textarea = useRef(null) + const picker = useRef(null) + const lastInput = useRef(null) + const dragDepth = useRef(0) + const [host, setHost] = useState(null) + const [dragOver, setDragOver] = useState(false) + useLayoutEffect(() => { + // The user row owns no action slot; its node seat and native action row + // are the anchors. This seat renders inside the same row, so both exist + // by the time layout effects run. + const seat = anchor.current?.closest('[data-chat-node-key]') + const actions = seat?.querySelector('[data-clock]') + if (seat && actions) setHost({ key: seat.getAttribute('data-chat-node-key'), actions }) + }, []) + const node = useChat(snapshot => (host ? snapshot.nodes.get(host.key) : undefined)) + const message = node?.kind === 'user' ? node.data : undefined + const key = message && `${sessionId}\u0000${message.messageId ?? host.key}` + const draft = useDraft(drafts, key) + const sessionSending = useSendingIn(drafts, sessionId) + const editing = draft !== undefined + useLayoutEffect(() => { + // Open, or come back into view, with the caret after the text. + const field = textarea.current + field?.setSelectionRange(field.value.length, field.value.length) + }, [editing]) + useEffect(() => { + // React's onBeforeInput reports typed text only; the native event also + // names deletions, which mention tracking needs. + const field = textarea.current + if (!field) return + const record = event => { lastInput.current = { inputType: event.inputType, start: field.selectionStart, end: field.selectionEnd } } + // An input that leaves the value as it was, such as pasting a mention's + // own text over it, still replaced what it selected, but React reports + // no change for it. + const unchanged = () => { + if (field.value !== drafts.get(key)?.text) return + drafts.setText(key, field.value, lastInput.current) + lastInput.current = null + } + field.addEventListener('beforeinput', record) + field.addEventListener('input', unchanged) + return () => { + field.removeEventListener('beforeinput', record) + field.removeEventListener('input', unchanged) + } + }, [editing, drafts, key]) + useLayoutEffect(() => { + const field = textarea.current + if (!field) return + field.style.height = 'auto' + field.style.height = `${field.scrollHeight}px` + }, [draft?.text]) + const running = useSession(session => session.running) + const canFork = useAccess('session/fork') + const canPrompt = useAccess('session/prompt') + const canUpload = useAccess('fileUploads/upload') !== false + const limits = useProjection('imageLimits') + const readSessionImage = useCallback(ref => readImage(sessionId, ref), [readImage, sessionId]) + // The edited message opens its Turn; the branch keeps everything before that + // Turn, so the previous Turn's end is the earliest history the cut needs. + const turn = node?.location?.turn + const previousEnd = useChat(snapshot => (turn ? snapshot.timeline.turns.get(turn.turn - 1)?.end?.seq : undefined)) + // Messages steered into the Turn go with it when the edit replaces it. + const steers = useChat(snapshot => (editing && turn ? snapshot.locations.getTurn(turn.turn).filter(nodeKey => snapshot.nodes.get(nodeKey)?.kind === 'steering').length : 0)) + if (!message || !canFork || !canPrompt || isSubagent(sessionId) || !Number.isSafeInteger(turn?.start?.seq)) return h('span', { ref: anchor, hidden: true }) + + const blocks = message.content + const start = () => { + const text = blocks.filter(block => block.type === 'text').map(block => block.text).join('\n\n') + drafts.open(key, message.seq, text, locateMentions(text, referencesOf(sessionId, message.seq))) + } + const pencil = createPortal(h('button', { + type: 'button', className: host.actions.querySelector('button:not([data-eduwork-message-edit])')?.className, 'data-eduwork-message-edit': '', + 'aria-label': t('edit'), title: running ? t('running') : t('edit'), 'aria-disabled': running || undefined, 'data-unavailable': running || undefined, + onClick: running ? undefined : start, + }, h(IconEditOutlineRegular, {})), host.actions) + if (!editing) return h(React.Fragment, null, h('span', { ref: anchor, hidden: true }), pencil) + + const { text, mentions, removed, added, sending, notice } = draft + const kept = blocks.map((block, index) => [block, index]).filter(([block, index]) => isAttachment(block) && !removed.has(index)) + const uploading = added.some(item => item.upload?.status === 'uploading') + const failed = added.find(item => item.upload?.status === 'error')?.upload + const empty = text.trim() === '' && kept.length === 0 && added.length === 0 + const canAdd = canUpload && !sending + const othersSending = sessionSending && !sending + const blocked = sending || othersSending || empty || uploading || failed + + const cancel = () => { if (!sending) drafts.close(key) } + const send = async () => { + if (blocked || !drafts.startSending(key, sessionId)) return + try { + await resend({ sessionId, turnStart: turn.start.seq, previousEnd, kept: kept.map(([block]) => block), added, text, mentions }) + drafts.close(key) + } catch (cause) { + const reason = cause?.copy ? t(cause.copy) : cause?.reason !== undefined ? refusalText(t, cause.reason, limits) : cause instanceof Error ? cause.message : String(cause) + drafts.update(key, () => ({ sending: false, notice: cause?.branch && !cause.copy ? t('branchFailed', { message: reason }) : reason })) + } + } + const intake = (files, directories) => { + if (!canAdd || files.length === 0) return + const refusal = drafts.addFiles(key, sessionId, files, { + directories, limits, keptImageBytes: kept.filter(([block]) => block.type === 'image').map(([block]) => block.attachment.bytes), + }) + if (refusal) { + const [copy, values] = refusal + drafts.update(key, () => ({ notice: t(copy, values.bytes === undefined ? values : { size: fileSizeText(values.bytes) }) })) + } + textarea.current?.focus() + } + // Drags carrying files stop here so the composer's document-level drop + // target neither shows its overlay over the editor nor takes the files. + const drop = { + onDragEnter: event => { + if (!carriesFiles(event)) return + event.preventDefault() + event.stopPropagation() + dragDepth.current++ + setDragOver(true) + }, + onDragOver: event => { + if (!carriesFiles(event)) return + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = canAdd ? 'copy' : 'none' + }, + onDragLeave: event => { + if (!carriesFiles(event)) return + event.stopPropagation() + dragDepth.current = Math.max(0, dragDepth.current - 1) + if (dragDepth.current === 0) setDragOver(false) + }, + onDrop: event => { + if (!carriesFiles(event)) return + event.preventDefault() + event.stopPropagation() + dragDepth.current = 0 + setDragOver(false) + const { files, directories } = transferFiles(event.dataTransfer.items) + intake(files, directories) + }, + } + const labels = name => ({ remove: t('remove', { name }), retry: t('retry', { name }), uploading: t('uploading') }) + // Like the native composer, a failed upload holds the send and says why. + const status = notice ?? (failed ? (failed.reason ? refusalText(t, failed.reason, limits) : t('uploadFailed')) : uploading ? t('uploadingHold') : othersSending ? t('othersSending') : t('hint')) + + return h(React.Fragment, null, + h('span', { ref: anchor, hidden: true }), + pencil, + h('div', { className: 'eduwork-message-editor', 'data-eduwork-message-editor': '', 'data-drop-active': dragOver || undefined, ...drop }, + kept.length + added.length > 0 && h('div', { className: 'eduwork-message-editor-attachments' }, + kept.map(([block, index]) => h(Chip, { + key: `original-${index}`, kind: block.type, state: 'ready', + name: block.attachment.name ?? t('image'), bytes: block.attachment.bytes, + image: block.type === 'image' && h(StoredImage, { attachment: block.attachment, readImage: readSessionImage }), + labels: labels(block.attachment.name ?? t('image')), disabled: sending, + onRemove: () => { drafts.removeOriginal(key, index); textarea.current?.focus() }, + })), + added.map(item => { + const upload = item.upload + const state = upload?.status === 'ready' ? 'ready' : upload?.status === 'error' ? (upload.reason ? 'refused' : 'failed') : 'uploading' + const name = item.file.name || t(item.kind === 'image' ? 'image' : 'file') + return h(Chip, { + key: item.id, kind: item.kind, state, name, bytes: item.file.size, + image: item.kind === 'image' && h('img', { src: item.previewUrl, alt: name }), + progress: upload?.status === 'uploading' && upload.total ? upload.loaded / upload.total : undefined, + problem: upload?.status !== 'error' ? undefined : upload.reason ? refusalText(t, upload.reason, limits) : t('uploadFailed'), + labels: labels(name), disabled: sending, + onRemove: () => { drafts.removeAdded(key, item.id); textarea.current?.focus() }, + onRetry: () => drafts.retry(key, sessionId, item.id), + }) + })), + h('textarea', { + ref: textarea, value: text, rows: 1, autoFocus: true, disabled: sending, + 'aria-label': t('edit'), + onChange: event => { + drafts.setText(key, event.currentTarget.value, lastInput.current) + lastInput.current = null + }, + onPaste: event => { + const { files, directories } = transferFiles(event.clipboardData.items) + if (files.length === 0) return + intake(files, directories) + // Text riding along with the files (a file name, say) still pastes. + if (event.clipboardData.getData('text/plain') === '') event.preventDefault() + }, + onKeyDown: event => { + // Shift+Enter is a newline; keys that confirm or dismiss an + // input-method candidate are not ours. + if (event.key !== 'Escape' && (event.key !== 'Enter' || event.shiftKey) || composing(event.nativeEvent)) return + event.preventDefault() + if (event.key === 'Escape') cancel() + else if (!event.repeat) void send() + }, + }), + // Like the sent message, name the conversations it will reference. + mentions.length > 0 && h('div', { className: 'eduwork-message-editor-references' }, + t('references', { labels: mentions.map(mention => mention.label).join(t('listSeparator')) })), + steers > 0 && h('div', { className: 'eduwork-message-editor-steers' }, t('steers', { count: steers })), + h('div', { className: 'eduwork-message-editor-foot' }, + canUpload && h('button', { + type: 'button', className: 'eduwork-message-editor-attach', 'aria-label': t('attach'), title: t('attach'), + disabled: sending, onClick: () => picker.current?.click(), + }, h(IconPaperclipOutlineRegular, { size: 16 })), + canUpload && h('input', { + ref: picker, type: 'file', multiple: true, hidden: true, + onChange: event => { + const files = [...event.currentTarget.files ?? []] + event.currentTarget.value = '' + intake(files) + }, + }), + h('span', { className: 'eduwork-message-editor-note', 'data-error': notice !== null || failed !== undefined || undefined, role: notice === null ? undefined : 'alert' }, status), + h('button', { type: 'button', onClick: cancel, disabled: sending }, t('cancel')), + h('button', { type: 'button', 'data-primary': '', onClick: () => void send(), disabled: blocked }, t('send'))))) +} diff --git a/packages/dsh-message-edit/src/client/fork-boundary.js b/packages/dsh-message-edit/src/client/fork-boundary.js new file mode 100644 index 00000000..a93191e0 --- /dev/null +++ b/packages/dsh-message-edit/src/client/fork-boundary.js @@ -0,0 +1,17 @@ +/** + * Choose the inclusive fork cut for a Turn: the previous Turn's end plus the + * standalone events after it, stopping before queued input or the Turn itself. + * This is the host's own rule for an omitted fork boundary, applied to an + * earlier Turn, so the branch inherits neither the edited message nor its + * queue entry. + */ +export function forkBoundary(events, turnStart) { + const before = events.filter(event => event.seq < turnStart) + let boundary = before.findLast(event => event.type === 'turn/end')?.seq ?? -1 + for (const event of before) { + if (event.seq <= boundary) continue + if (event.type === 'turn/start' || event.type === 'agent/inbox/spliced' || event.type === 'user/message' && event.surfaceOp === 'append') break + boundary = event.seq + } + return boundary +} diff --git a/packages/dsh-message-edit/src/client/index.jsx b/packages/dsh-message-edit/src/client/index.jsx new file mode 100644 index 00000000..f2d4242f --- /dev/null +++ b/packages/dsh-message-edit/src/client/index.jsx @@ -0,0 +1,273 @@ +import { observeComposition } from '@deepseek-ai/dsh-client-ui-primitives' +import { REMOTE } from '../remote.js' +import { EditDrafts } from './drafts.js' +import { forkBoundary } from './fork-boundary.js' +import { restoreMentions } from './mentions.js' +import { MessageEditSeat } from './editor.jsx' +import { SteeringPencils } from './steering.jsx' + +const NS = 'eduworkMessageEdit' +const COPY = { + zh: { + edit: '编辑消息', running: '对话进行中,结束后可编辑', + cancel: '取消', send: '发送', attach: '添加文件或图片', + remove: '移除 {name}', retry: '重试上传 {name}', image: '图片', file: '文件', + hint: '发送后用改过的对话替换当前对话,原对话移入归档;已做的文件修改不会撤销', + uploading: '上传中…', uploadFailed: '上传失败,点击重试', + uploadingHold: '附件还在上传,上传完成后才能发送', + othersSending: '这个对话里另一条编辑正在发送,发送完成后再发', + folder: '不能添加文件夹,请添加单个文件', + tooMany: '一条消息最多添加 {count} 张图片', imageTooLarge: '单张图片不能超过 {size}', imagesTooLarge: '图片总大小超过 {size},请移除部分图片', + modelUnsupported: '当前模型不支持图片,请切换支持图片的模型', tooManyPixels: '图片分辨率过大,请压缩后重试', + dimensionTooLarge: '图片宽高不能超过 {size}px,请缩小后重试', unsupportedType: '仅支持 PNG、JPG、WebP、GIF 格式的图片', + attachmentRefused: '附件无法发送({reason})', + attachmentMissing: '原消息的附件已找不到,请移除后再发送', + branchFailed: '已创建分支对话,但消息没有发出:{message}', + unconfirmed: '已创建分支对话,但连接中断,无法确认消息是否发出,请到侧栏的分支对话里查看', + historyFailed: '没能加载这条消息之前的记录,请稍后重试', + references: '引用会话 · {labels}', listSeparator: '、', + steering: '插话不能单独编辑,请编辑这一轮开头的消息', + steers: '这一轮还有 {count} 条插话,发送后会一起替换', + }, + en: { + edit: 'Edit message', running: 'Available when the conversation finishes', + cancel: 'Cancel', send: 'Send', attach: 'Add files or images', + remove: 'Remove {name}', retry: 'Retry uploading {name}', image: 'Image', file: 'File', + hint: 'Sending replaces this conversation with the edited one and archives the original; file changes are not undone', + uploading: 'Uploading…', uploadFailed: 'Upload failed; click to retry', + uploadingHold: 'Attachments are still uploading; send after they finish', + othersSending: 'Another edit of this conversation is being sent; send after it finishes', + folder: 'Folders cannot be added; add individual files', + tooMany: 'A message can include up to {count} images', imageTooLarge: 'Each image must be smaller than {size}', imagesTooLarge: 'Images exceed {size} in total; remove some', + modelUnsupported: 'The current model does not support images; switch to a model that does', tooManyPixels: 'Image resolution is too high; compress it and try again', + dimensionTooLarge: 'Image sides must be at most {size}px; downscale it and try again', unsupportedType: 'Only PNG, JPG, WebP, and GIF images are supported', + attachmentRefused: 'The attachment cannot be sent ({reason})', + attachmentMissing: 'An attachment of the original message is gone; remove it and send again', + branchFailed: 'A branch was created, but the message was not sent: {message}', + unconfirmed: 'A branch was created, but the connection dropped before the send was confirmed; check the branch in the sidebar', + historyFailed: 'Could not load the history before this message; try again later', + references: 'Referenced conversations · {labels}', listSeparator: ', ', + steering: 'Messages sent mid-reply cannot be edited on their own; edit the message that started this turn', + steers: 'This turn has {count} more message(s) sent mid-reply; sending replaces them too', + }, +} + +const CSS = ` +/* The slot wraps this seat in a display:contents element; hide the bubble and + action row that follow it while the message is being edited. */ +[data-slot="conversation.chat.user.before"]:has([data-eduwork-message-editor]) ~ * { display: none !important; } +.eduwork-message-editor { align-self: stretch; display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; + border-radius: 16px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); } +.eduwork-message-editor[data-drop-active] { border-color: var(--dsw-alias-brand-primary, #4d6bfe); background: var(--dsw-alias-interactive-bg-hover); } +.eduwork-message-editor textarea { width: 100%; box-sizing: border-box; resize: none; border: 0; outline: 0; padding: 0; + background: transparent; color: var(--dsw-alias-label-primary); font: inherit; + font-size: calc(14px + var(--dsh-content-font-delta, 0px)); line-height: 1.6; max-height: 50vh; overflow: auto; } +.eduwork-message-editor-attachments { display: flex; flex-wrap: wrap; gap: 8px; } +.eduwork-message-editor-attachment { position: relative; box-sizing: border-box; height: 64px; border-radius: var(--dsw-radius-lg, 12px); + border: .5px solid var(--dsw-alias-border-l2); background: var(--dsw-specific-input-major, transparent); overflow: hidden; } +.eduwork-message-editor-attachment[data-state="failed"], .eduwork-message-editor-attachment[data-state="refused"] { border-color: var(--dsw-alias-state-error-primary, #d54941); } +.eduwork-message-editor-attachment[data-kind="image"] { width: 64px; } +.eduwork-message-editor-image, .eduwork-message-editor-image img { width: 100%; height: 100%; display: block; } +.eduwork-message-editor-image img { object-fit: cover; } +.eduwork-message-editor-image > svg { display: block; margin: 22px auto; color: var(--dsw-alias-state-error-primary, #d54941); } +.eduwork-message-editor-attachment:is([data-state="uploading"], [data-state="failed"], [data-state="refused"]) img { opacity: .4; } +.eduwork-message-editor-attachment[data-kind="file"] { width: 240px; display: flex; align-items: center; gap: 10px; padding: 0 12px; } +.eduwork-message-editor-file-icon { flex: none; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; } +.eduwork-message-editor-spinner { width: 20px; height: 20px; box-sizing: border-box; border: 2px solid; border-top-color: transparent; border-radius: 50%; + animation: eduwork-message-editor-spin .8s linear infinite; } +@keyframes eduwork-message-editor-spin { to { transform: rotate(360deg); } } +.eduwork-message-editor-file-body { flex: 1; min-width: 0; display: flex; flex-direction: column; } +[data-state="failed"] :is(.eduwork-message-editor-file-body, .eduwork-message-editor-image) { cursor: pointer; } +.eduwork-message-editor-file-name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--dsw-alias-label-primary); + font-size: 14px; font-weight: 500; line-height: 22px; } +.eduwork-message-editor-file-meta { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--dsw-alias-label-tertiary); + font-size: 12px; line-height: 15px; } +:is([data-state="failed"], [data-state="refused"]) .eduwork-message-editor-file-meta { color: var(--dsw-alias-state-error-primary, #d54941); } +.eduwork-message-editor-progress { position: absolute; left: 8px; right: 8px; bottom: 5px; height: 2px; border-radius: 1px; overflow: hidden; + pointer-events: none; background: var(--dsw-alias-fill-tertiary, #00000014); } +.eduwork-message-editor-progress > span { display: block; height: 100%; width: 35%; border-radius: inherit; background: var(--dsw-alias-brand-primary, #4d6bfe); + animation: eduwork-message-editor-indeterminate 1.2s ease-in-out infinite alternate; } +@keyframes eduwork-message-editor-indeterminate { from { margin-left: 0; } to { margin-left: 65%; } } +.eduwork-message-editor .eduwork-message-editor-remove { position: absolute; top: 6px; right: 6px; width: 18px; height: 18px; min-height: 0; padding: 0; + display: inline-flex; align-items: center; justify-content: center; border-radius: 50%; opacity: 0; transition: opacity .2s ease-in-out; + color: var(--dsw-alias-label-primary-inverted, #fff); background: var(--dsw-alias-button-contrast-fill, #000000b8); } +.eduwork-message-editor .eduwork-message-editor-remove:hover:not(:disabled) { background: var(--dsw-alias-button-contrast-fill, #000000b8); } +.eduwork-message-editor-attachment:is(:hover, :focus-within, [data-state="failed"], [data-state="refused"]) .eduwork-message-editor-remove { opacity: 1; } +@media (hover: none), (pointer: coarse) { .eduwork-message-editor .eduwork-message-editor-remove { opacity: 1; } } +.eduwork-message-editor-references, .eduwork-message-editor-steers { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; } +.eduwork-message-editor-foot { display: flex; align-items: center; gap: 8px; } +.eduwork-message-editor .eduwork-message-editor-attach { padding: 0; width: 28px; display: inline-flex; align-items: center; justify-content: center; } +.eduwork-message-editor-note { flex: 1; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); } +.eduwork-message-editor-note[data-error] { color: var(--dsw-alias-state-error-primary, #d54941); } +.eduwork-message-editor button { min-height: 28px; padding: 4px 12px; border: 0; border-radius: 8px; font: inherit; font-size: 13px; cursor: pointer; + color: var(--dsw-alias-label-secondary); background: transparent; } +.eduwork-message-editor button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } +.eduwork-message-editor button[data-primary] { color: var(--dsw-alias-label-primary-foreground); background: var(--dsw-alias-button-primary-fill); } +.eduwork-message-editor button[data-primary]:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); } +.eduwork-message-editor button:disabled { opacity: .5; cursor: not-allowed; } +` + +/** An error the editor shows with one of its own messages. */ +const failure = (copy, extra = {}) => Object.assign(new Error(copy), { copy, ...extra }) + +/** A Host refusal; an attachment refusal keeps its reason for display. */ +const refusal = error => Object.assign(new Error(error.message), { reason: error.details?.reason }) + +async function unwrap(operation) { + const result = await operation + if (result.ok) return result.value + throw refusal(result.error) +} + +/** + * The branch a failed fork left behind: the Host can create the child and then + * fail to file it in the workspace, in which case `onCreated` never ran. + */ +const strandedChild = error => (error?.name === 'SessionForkError' && error.rpcError.code === 'session/workspace-attach-failed' ? error.rpcError.details.sessionId : undefined) + +/** + * Durable events of a Session from `seq` on. `loadThrough` returns early while + * a plain page load is in flight and fails soft, so wait that load out and + * confirm the window really reaches `seq`. + */ +async function historyFrom(session, seq) { + for (;;) { + await session.loadThrough(seq) + const events = session.eventSource.getSnapshot().entries.filter(entry => entry.type === 'event').map(entry => entry.event) + const { hasMore, loadingOlder } = session.getSnapshot() + if (!hasMore || events[0]?.seq <= seq) return events + if (!loadingOlder) throw failure('historyFailed') + await new Promise(resolve => { + const stop = session.subscribe(() => { + if (!session.getSnapshot().loadingOlder) { stop(); resolve() } + }) + }) + } +} + +export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'uiWorkspace', 'remote', 'fileUpload'] + +export async function apply(ctx) { + const disposeRemote = await ctx.remote.$mount(REMOTE) + ctx.effect(() => ctx.locale.register(NS, COPY), 'message-edit: dictionaries') + ctx.effect(() => { + const style = document.createElement('style') + style.dataset.eduworkMessageEditStyle = '' + style.textContent = CSS + document.head.append(style) + return () => style.remove() + }, 'message-edit: styles') + const composition = observeComposition(document) + ctx.effect(() => () => composition.dispose(), 'message-edit: composition guard') + + ctx.inject(['remote.eduworkMessageEdit'], surface => { + const drafts = new EditDrafts(surface.fileUpload) + surface.effect(() => () => drafts.dispose(), 'message-edit: drafts') + const readImage = (sessionId, ref) => surface.sessions.binding(sessionId).session.readAttachment(ref) + const isSubagent = sessionId => surface.sessions.subagentAddress(sessionId) !== undefined + // The Host files a message's referenced conversations as the event right + // after it, which the loaded window holds whenever the message is shown. + const referencesOf = (sessionId, seq) => { + const next = surface.sessions.binding(sessionId).session.eventSource.getSnapshot().entries + .find(entry => entry.type === 'event' && entry.event.seq === seq + 1)?.event + return next?.type === 'user/message' && next.data.source?.kind === 'session-reference' ? next.data.source.references : [] + } + const using = (sessionId, operation) => surface.sessions.using(sessionId, { source: 'eduworkMessageEdit' }, async reference => { + await reference.ready + return operation(reference.binding.session) + }) + + // Fork just before the edited message's Turn and send the edited message as + // the branch's next Turn. As editing does in Codex, the branch then takes + // the conversation's place: it keeps the title and pin, and the original + // is archived, unchanged and recoverable from the archive. + async function resend({ sessionId, turnStart, previousEnd, kept, added, text, mentions }) { + // Every attachment is a stored reference by now: the message's own, or + // one the edit uploaded to this Session. Like the native composer, the + // message carries its attachments first and the text after them. + const attachments = [...kept, ...added.map(item => ({ type: item.kind, attachment: item.upload.ref }))] + let childId + let userTitle + try { + // Hold the source while its history loads: the user may leave it meanwhile. + await using(sessionId, async source => { + const events = await historyFrom(source, previousEnd ?? 0) + const atSeq = forkBoundary(events, turnStart) + if (atSeq < 0) throw failure('historyFailed') + // The branch inherits every title set before the cut. A title the user + // set after it is carried over; a generated one is left to regenerate, + // since renaming would pin it and stop automatic titles. + const title = events.findLast(event => event.type === 'session/title') + if (title?.seq > atSeq && title.data.source.kind === 'user') userTitle = title.data.title + // `onCreated` names the child before the title rename that may still fail. + await surface.sessions.fork({ sessionId, atSeq, increaseTitle: false, onCreated: id => { childId = id } }) + }) + await using(childId, async child => { + if (userTitle !== undefined) await unwrap(child.rename(userTitle)) + // Input queued while an earlier Turn ran is part of the inherited prefix; + // the edited message replaces it, so drop it before anything wakes the + // branch. Removal alone does not start a Turn. + const inbox = child.projections.get('inbox') + for (const item of [...inbox?.['next-turn'] ?? [], ...inbox?.['next-step'] ?? []]) await unwrap(child.updateQueue(item.id, { kind: 'remove' })) + const receipts = attachments.length === 0 ? [] : await unwrap(surface.remote.eduworkMessageEdit.stageAttachments(sessionId, childId, attachments)) + const content = receipts.map(receiptId => ({ type: 'file', receiptId })) + if (text.trim() !== '') content.push({ type: 'text', text: restoreMentions(text, mentions) }) + // Only a Host reply proves the prompt was refused; a throw or a lost + // connection leaves it unknown, and that branch may already be running. + const result = await child.prompt(content, 'queue').catch(() => ({ ok: false, error: { code: 'connection/unknown' } })) + if (!result.ok) throw result.error.code.startsWith('connection/') ? failure('unconfirmed', { branch: childId }) : refusal(result.error) + }) + } catch (cause) { + childId ??= strandedChild(cause) + const error = cause instanceof Error ? cause : new Error(String(cause)) + // A refused send leaves the branch holding only copied history: archive + // it rather than leave an empty conversation behind, and say so if that + // fails too. + if (childId !== undefined && error.branch === undefined) await surface.uiWorkspace.archiveSession(childId).catch(() => { error.branch = childId }) + throw error + } + // Whoever opens the original until it is archived lands on its + // replacement instead: archiving clears the selection, which would leave + // them on an empty page. The send already succeeded, so a failed pin or + // archive only leaves the original listed. + const view = surface.sessions.retainInfo(sessionId) + let wasViewing = false + let asked = false + const follow = () => { + const viewing = Boolean(view.getSnapshot().retainedBy.mainView) + if (viewing && !wasViewing) asked = true + wasViewing = viewing + } + follow() + const stopFollowing = view.subscribe(follow) + try { + if (surface.workspaces.list.getSnapshot().pinnedSessionIds.includes(sessionId)) await surface.uiWorkspace.pinSession(childId).catch(() => {}) + drafts.moveSession(sessionId, childId, turnStart) + if (asked) { + asked = false + // Leave the original only once it has finished opening: a Session + // left before it renders is dropped mid-opening. + await using(sessionId, () => {}).catch(() => {}) + surface.uiWorkspace.openSession(childId) + } + await surface.uiWorkspace.archiveSession(sessionId).catch(() => {}) + } finally { + stopFollowing() + } + // Opened while being archived: the original is gone, so show its replacement. + if (asked) surface.uiWorkspace.openSession(childId) + } + + // A chain seat elects one occupant; the lowest precedence lets any future + // message-source decoration win over this invisible seat. + surface.slots.inject('conversation.chat.user.before', () => surface.slots.register({ + name: 'conversation.chat.user.before', id: 'eduwork-message-edit', priority: 1000, locale: NS, + select: owner => (owner.kind === 'user' ? true : null), + inject: () => ({ drafts, resend, readImage, referencesOf, isSubagent, composing: event => composition.guards(event) }), + }, MessageEditSeat)) + }) + + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'eduwork-message-edit.steering', locale: NS }, SteeringPencils)) + + return async () => { await disposeRemote() } +} diff --git a/packages/dsh-message-edit/src/client/mentions.js b/packages/dsh-message-edit/src/client/mentions.js new file mode 100644 index 00000000..9cfb717e --- /dev/null +++ b/packages/dsh-message-edit/src/client/mentions.js @@ -0,0 +1,91 @@ +// Conversations a message references, tracked through an edit. +// +// The Host stores a referenced conversation as readable `@label` text plus a +// separate snapshot message, so the editor shows plain text. The draft keeps +// where each reference sits and follows the user's edits; a reference whose +// `@label` survives untouched is sent again in the Host's canonical form, so +// the branch captures a fresh snapshot. Wherever that cannot be told for +// certain the reference is dropped rather than guessed: a reference sent +// that the user removed is worse than one the editor shows as gone. + +/** The Host's canonical session URI: unpadded base64url of the id as a JSON string. */ +const sessionUri = sessionId => `dsh-session:${btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(sessionId)))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}` + +const canonical = ({ sessionId, label }) => `@[${label.replace(/[\\\]]/g, '\\$&')}](${sessionUri(sessionId)})` + +/** + * Where a stored message's references sit in its text. The Host keeps only + * the readable `@label`, so a reference is located only when its `@label` + * occurs exactly once: a repeated or overlapping one could be text the user + * typed, and is left out. + */ +export function locateMentions(text, references) { + return references.flatMap(({ sessionId, label }) => { + const mention = `@${label}` + const start = text.indexOf(mention) + return start >= 0 && text.indexOf(mention, start + 1) < 0 ? [{ sessionId, label, start, end: start + mention.length }] : [] + }).filter((mention, _, all) => all.every(other => other === mention || other.end <= mention.start || other.start >= mention.end)) + .sort((a, b) => a.start - b.start) +} + +// Input types that replace the selection with what they insert. +const REPLACES_SELECTION = new Set(['insertText', 'insertFromPaste', 'insertFromYank', 'insertLineBreak', 'insertParagraph', 'insertCompositionText', + 'deleteByCut', 'deleteContent', 'deleteContentBackward', 'deleteContentForward', 'deleteWordBackward', 'deleteWordForward', + 'deleteSoftLineBackward', 'deleteSoftLineForward', 'deleteHardLineBackward', 'deleteHardLineForward']) + +/** + * The stretch of `before` one input replaced, from the selection and input + * type recorded just before it; undefined when the input does not say. + */ +function changedStretch(before, after, edit) { + if (!edit || !REPLACES_SELECTION.has(edit.inputType)) return undefined + let { start, end } = edit + // A deletion from a bare caret removes what lies to one side of it. + if (start === end && edit.inputType.startsWith('delete')) { + const removed = before.length - after.length + if (edit.inputType.endsWith('Backward')) start -= removed + else end += removed + } + const kept = before.length - end + const fits = start >= 0 && start <= end && after.length >= start + kept + && after.startsWith(before.slice(0, start)) && after.endsWith(before.slice(end)) + return fits ? { start, end } : undefined +} + +/** + * The widest stretch an input of another kind, such as undo, a drop, or a + * spelling replacement, may have changed: wherever the texts differ however + * they are matched up, and any selection it may have replaced. + */ +function possibleStretch(before, after, edit) { + const shortest = Math.min(before.length, after.length) + let head = 0 + while (head < shortest && before[head] === after[head]) head++ + let tail = 0 + while (tail < shortest && before[before.length - 1 - tail] === after[after.length - 1 - tail]) tail++ + const start = Math.min(head, shortest - tail) + const end = before.length - Math.min(tail, shortest - head) + return edit.start < edit.end ? { start: Math.min(start, edit.start), end: Math.max(end, edit.end) } : { start, end } +} + +/** + * Carry mentions across one input. `edit` is the selection and input type the + * textarea announced before it; a change it did not announce cannot be + * placed, so no mention survives it. + */ +export function followEdit(mentions, before, after, edit) { + if (mentions.length === 0 || !edit) return [] + const stretch = changedStretch(before, after, edit) ?? possibleStretch(before, after, edit) + const shift = after.length - before.length + return mentions.flatMap(mention => { + if (mention.end <= stretch.start) return [mention] + if (mention.start >= stretch.end) return [{ ...mention, start: mention.start + shift, end: mention.end + shift }] + return [] + }) +} + +/** The text to send, with every surviving mention in canonical form. */ +export function restoreMentions(text, mentions) { + return mentions.reduceRight((restored, mention) => restored.slice(0, mention.start) + canonical(mention) + restored.slice(mention.end), text) +} diff --git a/packages/dsh-message-edit/src/client/steering.jsx b/packages/dsh-message-edit/src/client/steering.jsx new file mode 100644 index 00000000..843a4ea6 --- /dev/null +++ b/packages/dsh-message-edit/src/client/steering.jsx @@ -0,0 +1,41 @@ +import React, { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { IconEditOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' + +const h = React.createElement +const ROWS = '[data-chat-flow-kind="steering"] [data-clock]' + +const sameRows = (a, b) => a.length === b.length && a.every((row, index) => row === b[index]) + +/** + * A message steered into a running Turn cannot be edited on its own: it + * joined the Turn partway, after work a resend cannot undo. Its row shows the + * pencil unavailable and points to the message that opened the Turn, which + * the edit replaces together with it. + * + * Chat renders no plugin seat on steering rows, and a seat on the opening + * row unmounts when it scrolls out of the virtualized list while its steered + * rows may still show, so one always-mounted overlay finds the rows instead. + */ +export function SteeringPencils({ t }) { + const [rows, setRows] = useState([]) + useEffect(() => { + let frame = 0 + const scan = () => { + frame = 0 + const found = [...document.querySelectorAll(ROWS)] + setRows(current => (sameRows(current, found) ? current : found)) + } + const observer = new MutationObserver(() => { frame ||= requestAnimationFrame(scan) }) + observer.observe(document.body, { childList: true, subtree: true }) + scan() + return () => { + observer.disconnect() + cancelAnimationFrame(frame) + } + }, []) + return rows.map((row, index) => createPortal(h('button', { + type: 'button', className: row.querySelector('button:not([data-eduwork-steering-edit])')?.className, 'data-eduwork-steering-edit': '', + 'aria-label': t('edit'), title: t('steering'), 'aria-disabled': true, 'data-unavailable': true, + }, h(IconEditOutlineRegular, {})), row, index)) +} diff --git a/packages/dsh-message-edit/src/index.js b/packages/dsh-message-edit/src/index.js new file mode 100644 index 00000000..63db6d81 --- /dev/null +++ b/packages/dsh-message-edit/src/index.js @@ -0,0 +1,48 @@ +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +/** + * Re-stage attachments of a conversation as upload receipts of a branch forked + * from it, so the edited message can cite them. Bytes move from the attachment + * store to the upload service inside the Host; the browser never downloads or + * re-uploads them. + * + * As with the Host's own attachment reads, a stored reference is honoured + * within the caller's access to the conversation it names. A call reads from + * one conversation and writes to another, so access control checks both. + */ +export default class MessageEditService extends TypertRemoteService { + static inject = ['attachments', 'fileUploads'] + + constructor(ctx) { + super(ctx, 'eduworkMessageEdit') + } + + /** `sourceSessionId` names the conversation the attachments come from; `sessionId` the branch. */ + async stageAttachments(sourceSessionId, sessionId, attachments) { + const { attachments: store, fileUploads } = this.ctx + const receipts = [] + try { + for (const { type, attachment } of attachments) { + const staged = type === 'file' + ? await fileUploads.uploadStream({ sessionId, name: attachment.name, data: store.readFileStream(attachment) }) + : await fileUploads.uploadImageStream({ + sessionId, mediaType: attachment.mediaType, name: attachment.name, + data: [(await store.readImage(attachment)).data], + }) + receipts.push(staged.receiptId) + } + } catch (error) { + // Reading an image happens out here, not inside the upload service, so + // its storage failure needs the same Remote shape the service gives. + if (!store.isAttachmentError(error)) throw error + throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) + } + return receipts + } +} + +// Plain JavaScript has no decorator syntax: apply it by hand, marking the prototype once. +Remote({ name: 'stageAttachments', resources: { session: (sourceSessionId, sessionId) => [sourceSessionId, sessionId] } })( + MessageEditService.prototype.stageAttachments, + { kind: 'method', name: 'stageAttachments', static: false, private: false, addInitializer: initialize => initialize.call(Object.create(MessageEditService.prototype)) }, +) diff --git a/packages/dsh-message-edit/src/remote.js b/packages/dsh-message-edit/src/remote.js new file mode 100644 index 00000000..3ccf6241 --- /dev/null +++ b/packages/dsh-message-edit/src/remote.js @@ -0,0 +1,44 @@ +// Remote definition shared by the Host service and the browser client. +import { z } from 'zod' +import { createTypertRemoteInfo } from '@deepseek-ai/dsh-typert-protocol/remote-info' + +const pkg = '@eduwork/dsh-message-edit' +const strict = (typeSymbol, schema) => ({ mode: 'strict', typeSymbol, create: () => schema }) +const parameter = (name, typeSymbol, schema) => ({ + name, wire: name, source: 'json', codec: strict(typeSymbol, schema), strictCodec: strict(typeSymbol, schema), +}) + +const file = z.object({ attachmentId: z.string(), name: z.string(), bytes: z.number().int().nonnegative() }) +const image = z.object({ + attachmentId: z.string(), + mediaType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif']), + bytes: z.number().int().nonnegative(), + width: z.number().int().positive(), + height: z.number().int().positive(), + name: z.string().optional(), + originalDimensions: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(), +}) +const attachment = z.discriminatedUnion('type', [ + z.object({ type: z.literal('file'), attachment: file }), + z.object({ type: z.literal('image'), attachment: image }), +]) + +export const REMOTE = createTypertRemoteInfo({ + package: pkg, + methods: { + 'eduworkMessageEdit/stageAttachments': { + service: 'eduworkMessageEdit', + description: 'Stage attachments of a Session as upload receipts of a branch forked from it.', + invocation: { kind: 'direct' }, + parameters: [ + parameter('sourceSessionId', `${pkg}#SessionId`, z.string().min(1)), + parameter('sessionId', `${pkg}#SessionId`, z.string().min(1)), + parameter('attachments', `${pkg}#MessageAttachments`, z.array(attachment)), + ], + result: strict(`${pkg}#ReceiptIds`, z.array(z.string())), + sourceLocation: { file: 'src/index.js', line: 1, column: 1 }, + }, + }, +}, z) + +export default REMOTE diff --git a/packages/dsh-message-edit/src/typert.host.js b/packages/dsh-message-edit/src/typert.host.js new file mode 100644 index 00000000..0c611cce --- /dev/null +++ b/packages/dsh-message-edit/src/typert.host.js @@ -0,0 +1,9 @@ +import { REMOTE } from './remote.js' + +export const TYPERT = { + package: '@eduwork/dsh-message-edit', + face: 'host', + schemas: [], + get invocations() { return REMOTE.descriptors }, + model: { services: [], events: [], objects: [] }, +} diff --git a/packages/dsh-message-edit/test/fork-boundary.test.mjs b/packages/dsh-message-edit/test/fork-boundary.test.mjs new file mode 100644 index 00000000..c4b39225 --- /dev/null +++ b/packages/dsh-message-edit/test/fork-boundary.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { forkBoundary } from '../src/client/fork-boundary.js' + +const turn = (n, first) => [ + { seq: first, type: 'turn/start' }, + { seq: first + 1, type: 'user/message', surfaceOp: 'append' }, + { seq: first + 2, type: 'assistant/message' }, + { seq: first + 3, type: 'turn/end' }, +] + +test('cuts after the previous Turn and the standalone events that follow it', () => { + const events = [...turn(1, 0), { seq: 4, type: 'session/title' }, ...turn(2, 5)] + assert.equal(forkBoundary(events, 5), 4) +}) + +test('the first Turn cuts before everything after the Session header', () => { + assert.equal(forkBoundary([{ seq: 0, type: 'session/title' }, ...turn(1, 1)], 1), 0) + assert.equal(forkBoundary(turn(1, 0), 0), -1) +}) + +test('input queued while the previous Turn ran stays out of the branch', () => { + const events = [...turn(1, 0), { seq: 4, type: 'agent/inbox/spliced' }, { seq: 5, type: 'user/message', surfaceOp: 'append' }, ...turn(2, 6)] + assert.equal(forkBoundary(events, 6), 3) + // A queued entry recorded inside the previous Turn is cut with it. + const queuedEarly = [{ seq: 0, type: 'turn/start' }, { seq: 1, type: 'agent/inbox/queued' }, { seq: 2, type: 'turn/end' }, ...turn(2, 3)] + assert.equal(forkBoundary(queuedEarly, 3), 2) +}) diff --git a/packages/dsh-message-edit/test/mentions.test.mjs b/packages/dsh-message-edit/test/mentions.test.mjs new file mode 100644 index 00000000..28a34a97 --- /dev/null +++ b/packages/dsh-message-edit/test/mentions.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { parseSessionReferenceText } from '@deepseek-ai/dsh-session-reference' +import { followEdit, locateMentions, restoreMentions } from '../src/client/mentions.js' + +// Replay an input the way a textarea reports it: its beforeinput selection and type, then the new value. +function type(text, mentions, at, insert, remove = 0, inputType = insert ? 'insertText' : 'deleteContent') { + const after = text.slice(0, at) + insert + text.slice(at + remove) + return [after, followEdit(mentions, text, after, { inputType, start: at, end: at + remove })] +} +// What the Host makes of the text sent: readable text and the referenced sessions. +const host = (text, mentions) => parseSessionReferenceText(restoreMentions(text, mentions)) +const ids = parsed => parsed.references.map(reference => reference.sessionId) + +test('round-trips through the Host parser, escaping included', () => { + const references = [{ sessionId: '会话-1', label: 'a]b\\c' }, { sessionId: 's2', label: 'B' }] + const stored = '看 @a]b\\c 和 @B' + const parsed = host(stored, locateMentions(stored, references)) + assert.equal(parsed.text, stored) + assert.deepEqual(parsed.references, references) +}) + +test('a label that prefixes another stays with its own mention', () => { + const stored = '@AB 和 @A' + const mentions = locateMentions(stored, [{ sessionId: 'ab', label: 'AB' }, { sessionId: 'a', label: 'A' }]) + const [edited, kept] = type(stored, mentions, stored.indexOf(' 和 @A'), '', ' 和 @A'.length) + assert.equal(edited, '@AB') + assert.deepEqual(ids(host(edited, kept)), ['ab']) +}) + +test('typed text that looks like a mention stays text', () => { + const stored = '看 @X' + const mentions = locateMentions(stored, [{ sessionId: 'x', label: 'X' }]) + const [edited, kept] = type(stored, mentions, 0, '@X ') + assert.equal(edited, '@X 看 @X') + assert.equal(restoreMentions(edited, kept), `@X 看 @[X](dsh-session:${Buffer.from('"x"').toString('base64url')})`) +}) + +test('an edit inside a mention drops it; one next to it keeps it', () => { + const stored = '@X' + const mentions = locateMentions(stored, [{ sessionId: 'x', label: 'X' }]) + assert.deepEqual(type(stored, mentions, 1, 'Y')[1], []) + assert.deepEqual(ids(host(...type(stored, mentions, 0, '@'))), ['x']) + assert.deepEqual(ids(host(...type(stored, mentions, 2, 'X'))), ['x']) +}) + +test('an ambiguous @label is not taken for its reference', () => { + const locate = (stored, references) => locateMentions(stored, references).map(mention => mention.sessionId) + assert.deepEqual(locate('@see @B 和 @B', [{ sessionId: 'outer', label: 'see @B' }, { sessionId: 'b', label: 'B' }]), ['outer']) + assert.deepEqual(locate('@AB 和 @A', [{ sessionId: 'a', label: 'A' }]), []) + assert.deepEqual(locate('@X 对比 @X', [{ sessionId: 'first', label: 'X' }, { sessionId: 'second', label: 'X' }]), []) +}) + +test('pasting over a mention drops it even when the paste starts with the same text', () => { + const stored = '看 @X' + const mentions = locateMentions(stored, [{ sessionId: 'x', label: 'X' }]) + assert.deepEqual(type(stored, mentions, 2, '@XY', 2, 'insertFromPaste')[1], []) +}) + +test('backspace and forward delete take the side of the caret they remove', () => { + const stored = 'a@X' + const mentions = locateMentions(stored, [{ sessionId: 'x', label: 'X' }]) + const backspace = (at, inputType) => followEdit(mentions, stored, stored.slice(0, at - 1) + stored.slice(at), { inputType, start: at, end: at }) + assert.equal(backspace(1, 'deleteContentBackward').length, 1) + assert.equal(followEdit(mentions, stored, 'aX', { inputType: 'deleteContentForward', start: 1, end: 1 }).length, 0) +}) + +test('an unannounced change keeps only mentions it cannot have touched', () => { + const stored = '@X 和 @Y 和' + const mentions = locateMentions(stored, [{ sessionId: 'x', label: 'X' }, { sessionId: 'y', label: 'Y' }]) + const undo = after => followEdit(mentions, stored, after, { inputType: 'historyUndo', start: 0, end: 0 }).map(mention => mention.sessionId) + assert.deepEqual(undo('@X 和 @Y'), ['x', 'y']) + assert.deepEqual(undo('@X 和 @Y 和 好'), ['x', 'y']) + // Removing "@X 和 " could as well have kept X's "@" and dropped Y's: neither survives. + assert.deepEqual(undo('@Y 和'), []) + // A change the textarea never announced cannot be placed. + assert.deepEqual(followEdit(mentions, stored, '@X 和 @Y', undefined), []) +}) + +test('replacing a selected mention drops it, even with the same or longer text', () => { + const stored = '看 @X 吧' + const mentions = locateMentions(stored, [{ sessionId: 'x', label: 'X' }]) + const selected = inputType => ({ inputType, start: 2, end: 4 }) + assert.deepEqual(followEdit(mentions, stored, stored, selected('insertFromPaste')), []) + assert.deepEqual(followEdit(mentions, stored, stored, selected('insertCompositionText')), []) + assert.deepEqual(followEdit(mentions, stored, '看 @XY 吧', selected('insertReplacementText')), []) + assert.deepEqual(followEdit(mentions, stored, '看 @XY 吧', selected('insertFromDrop')), []) +}) diff --git a/packages/dsh-message-edit/test/runtime-resolver.mjs b/packages/dsh-message-edit/test/runtime-resolver.mjs new file mode 100644 index 00000000..fe9b0c7d --- /dev/null +++ b/packages/dsh-message-edit/test/runtime-resolver.mjs @@ -0,0 +1,10 @@ +import { registerHooks } from 'node:module' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +if (!process.env.EDUWORK_MESSAGE_EDIT_RUNTIME) throw Error('Set EDUWORK_MESSAGE_EDIT_RUNTIME to the locked DSH Runtime directory') +const runtime = pathToFileURL(resolve(process.env.EDUWORK_MESSAGE_EDIT_RUNTIME, 'package.json')).href +// The Host provides DSH packages and zod; resolve them from the locked Runtime. +registerHooks({ resolve(specifier, context, next) { + if (specifier.startsWith('@deepseek-ai/') || specifier === 'zod') return next(specifier, { ...context, parentURL: runtime }) + return next(specifier, context) +} }) diff --git a/packages/dsh-message-edit/test/stage-attachments.test.mjs b/packages/dsh-message-edit/test/stage-attachments.test.mjs new file mode 100644 index 00000000..342850a1 --- /dev/null +++ b/packages/dsh-message-edit/test/stage-attachments.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import MessageEditService from '../lib/index.js' + +const image = { type: 'image', attachment: { attachmentId: 'sha256:aa', mediaType: 'image/png', bytes: 3, width: 1, height: 1, name: 'a.png' } } +const file = { type: 'file', attachment: { attachmentId: 'sha256:bb', name: 'b.txt', bytes: 2 } } + +function fakeCtx({ readImage } = {}) { + const calls = [] + const storeError = Object.assign(new Error('gone'), { code: 'ATTACHMENT_NOT_FOUND', attachment: true }) + return { + calls, storeError, + attachments: { + readImage: readImage ?? (async () => ({ data: new Uint8Array([1, 2, 3]) })), + readFileStream: async function* () { yield new Uint8Array([4, 5]) }, + isAttachmentError: error => error?.attachment === true, + }, + fileUploads: { + uploadStream: async request => { calls.push(['file', request.sessionId, request.name]); return { receiptId: `r${calls.length}` } }, + uploadImageStream: async request => { calls.push(['image', request.sessionId, request.mediaType]); return { receiptId: `r${calls.length}` } }, + }, + } +} +const stage = (ctx, attachments) => MessageEditService.prototype.stageAttachments.call({ ctx }, 'session-source', 'session-branch', attachments) + +test('stages images and files into the branch in order', async () => { + const ctx = fakeCtx() + assert.deepEqual(await stage(ctx, [image, file]), ['r1', 'r2']) + assert.deepEqual(ctx.calls, [['image', 'session-branch', 'image/png'], ['file', 'session-branch', 'b.txt']]) +}) + +test('access control sees both conversations the call touches', () => { + const [marker] = remoteMethods(Object.create(MessageEditService.prototype)) + assert.equal(marker.method, 'stageAttachments') + assert.deepEqual(marker.resources.session('session-source', 'session-branch', []), ['session-source', 'session-branch']) +}) + +test('a stored image that cannot be read becomes an attachment refusal with its reason', async () => { + const ctx = fakeCtx({ readImage: async () => { throw ctx.storeError } }) + await assert.rejects(stage(ctx, [image]), error => error.code === 'session/attachment-invalid' && error.details.reason === 'ATTACHMENT_NOT_FOUND') +}) + +test('other failures pass through unchanged', async () => { + const boom = new Error('boom') + const ctx = fakeCtx({ readImage: async () => { throw boom } }) + await assert.rejects(stage(ctx, [image]), error => error === boom) +}) diff --git a/scripts/packages/archive.mjs b/scripts/packages/archive.mjs index 4d2a9645..3b98300c 100644 --- a/scripts/packages/archive.mjs +++ b/scripts/packages/archive.mjs @@ -52,7 +52,7 @@ export function inspectArchive(bytes, selected, expectedVersion) { assert.ok(files.has(file), `Missing decision plugin file ${file}`) } } - if (selected.id === 'dsh-side-chat' || selected.id === 'dsh-annotation') { + if (['dsh-side-chat', 'dsh-message-edit', 'dsh-annotation'].includes(selected.id)) { assert.ok(!manifest.private, 'Conversation plugin archive must be publishable') assert.equal(manifest.dsh?.bundle?.patch, './cordis.patch.yml', 'Missing native plugin bundle registration') for (const file of ['lib/index.js', 'lib/client.js', 'cordis.patch.yml', 'README.md', 'README_EN.md', 'LICENSE']) { diff --git a/scripts/packages/catalog.mjs b/scripts/packages/catalog.mjs index ecd3e18f..f172ba3e 100644 --- a/scripts/packages/catalog.mjs +++ b/scripts/packages/catalog.mjs @@ -7,6 +7,7 @@ export const packages = [ { id: 'dsh-decision', name: '@eduwork/dsh-decision', directory: 'packages/dsh-decision', group: 'dsh-decision' }, { id: 'dsh-annotation', name: '@eduwork/dsh-annotation', directory: 'packages/dsh-annotation', group: 'dsh-annotation' }, { id: 'dsh-side-chat', name: '@eduwork/dsh-side-chat', directory: 'packages/dsh-side-chat', group: 'dsh-side-chat' }, + { id: 'dsh-message-edit', name: '@eduwork/dsh-message-edit', directory: 'packages/dsh-message-edit', group: 'dsh-message-edit' }, { id: 'dsh-desktop-pet', name: '@eduwork/dsh-desktop-pet', directory: 'packages/dsh-desktop-pet', group: 'dsh-desktop-pet' }, { id: 'dsh-literature', name: '@eduwork/dsh-literature', directory: 'packages/dsh-literature', group: 'dsh-literature' }, { id: 'dsh-oidc', name: '@eduwork/dsh-oidc', directory: 'packages/dsh-oidc', group: 'dsh-oidc' }, @@ -30,9 +31,10 @@ export function changedGroups(files) { if (file.startsWith('scripts/packages/') || file === 'tests/package-maintenance.test.mjs' || /^\.github\/workflows\/packages(?:-release)?\.yml$/.test(file)) { groups.forEach(group => result.add(group)) } - // Both package checks install this Runtime to exercise their mounted plugins. + // These package checks install this Runtime to exercise their mounted plugins. if (/^third_party\/dsh\/[^/]+\/npm-runtime\//.test(file)) { result.add('dsh-side-chat') + result.add('dsh-message-edit') result.add('dsh-decision') } for (const group of groups) if (file.startsWith(`packages/${group}/`)) result.add(group) diff --git a/scripts/packages/manage.mjs b/scripts/packages/manage.mjs index 77ff2a80..ee1e4680 100644 --- a/scripts/packages/manage.mjs +++ b/scripts/packages/manage.mjs @@ -29,6 +29,7 @@ if (action === 'list') { 'dsh-decision': ['check'], 'dsh-annotation': ['check'], 'dsh-side-chat': ['check'], + 'dsh-message-edit': ['check'], 'dsh-desktop-pet': ['check'], 'dsh-literature': ['check'], 'dsh-oidc': ['check'], diff --git a/tests/ci-changes.test.mjs b/tests/ci-changes.test.mjs index 738cc470..b1c427bc 100644 --- a/tests/ci-changes.test.mjs +++ b/tests/ci-changes.test.mjs @@ -70,7 +70,7 @@ test('Web and DSH workflows keep product checks and select only affected npm pac } assert.deepEqual(selectProductChecks([web, dsh, 'packages/dsh-decision/src/core.js']).groups, ['dsh-decision']) assert.deepEqual(selectProductChecks([web, dsh, 'third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime/package-lock.json']).groups, - ['dsh-decision', 'dsh-side-chat']) + ['dsh-decision', 'dsh-side-chat', 'dsh-message-edit']) assert.deepEqual(selectProductChecks([web, dsh, '.github/workflows/new-check.yml']).groups, groups) }) @@ -90,7 +90,7 @@ test('direct source integration dependencies and mixed changes retain their chec assert.equal(nativeCatalog.integration, true) assert.deepEqual(nativeCatalog.groups, ['dsh-knowledge-studio']) const runtime = selectProductChecks(['third_party/dsh/candidate-v0.2.2-rc.1/npm-runtime/package-lock.json']) - assert.deepEqual(runtime.groups, ['dsh-decision', 'dsh-side-chat']) + assert.deepEqual(runtime.groups, ['dsh-decision', 'dsh-side-chat', 'dsh-message-edit']) assert.equal(runtime.build, true) assert.equal(runtime.integration, true) const mixed = selectProductChecks(['README.md', 'packages/dsh-annotation/src/index.js', 'third_party/npm-015-rc1/dsh-oidc/LOCK.json']) diff --git a/tests/package-maintenance.test.mjs b/tests/package-maintenance.test.mjs index 5542faac..ffd86d5b 100644 --- a/tests/package-maintenance.test.mjs +++ b/tests/package-maintenance.test.mjs @@ -23,7 +23,7 @@ test('shared-service changes select Studio integration; unrelated packages and d assert.deepEqual(changedGroups(['packages/dsh-knowledge-studio/packages/artifact-services/lib/speech.js']), ['dsh-knowledge-studio']) assert.deepEqual(changedGroups(['packages/dsh-oidc/src/host/model-provider.ts']), ['dsh-oidc']) assert.deepEqual(changedGroups(['packages/dsh-mail/README.md', 'README.md']), []) - assert.equal(changedGroups(['scripts/packages/release.mjs']).length, 11) + assert.equal(changedGroups(['scripts/packages/release.mjs']).length, 12) assert.deepEqual(changedGroups(['packages/dsh-decision/src/core.js']), ['dsh-decision']) assert.deepEqual(changedGroups(['packages/dsh-annotation/src/client/selection-actions.js']), ['dsh-annotation', 'dsh-side-chat']) assert.equal(needsProductBuild(['packages/dsh-oidc/src/host/model-provider.ts', 'docs/PACKAGES.md']), false) @@ -31,8 +31,8 @@ test('shared-service changes select Studio integration; unrelated packages and d assert.equal(needsProductBuild(['dsh-host/entry.mjs']), true) assert.throws(() => selectPackage('../../private'), /Unknown package/) }) -test('twelve separate package identities retain registry dependency contracts and product npm locks', async () => { - assert.equal(new Set(packages.map(p => p.name)).size, 12) +test('thirteen separate package identities retain registry dependency contracts and product npm locks', async () => { + assert.equal(new Set(packages.map(p => p.name)).size, 13) for (const selected of packages) { const pkg = JSON.parse(await readFile(new URL(`../${selected.directory}/package.json`, import.meta.url))) assert.equal(pkg.name, selected.name) @@ -44,7 +44,7 @@ test('twelve separate package identities retain registry dependency contracts an const assembly = JSON.parse(await readFile(new URL('../config/assembly.eduwork.json', import.meta.url))) for (const selected of packages) { const declaration = assembly.externalPackages.find(p => p.name === selected.name) - if (['dsh-external-agents', 'dsh-decision', 'dsh-desktop-pet', 'dsh-annotation', 'dsh-side-chat', 'dsh-remote-access'].includes(selected.id)) { + if (['dsh-external-agents', 'dsh-decision', 'dsh-desktop-pet', 'dsh-annotation', 'dsh-side-chat', 'dsh-message-edit', 'dsh-remote-access'].includes(selected.id)) { // DSH 0.2 packages; never add them to the legacy 0.1.x product. assert.equal(declaration, undefined) continue @@ -120,7 +120,7 @@ test('publication rejects the wrong package, missing entry points and archive tr }) test('conversation packages need their native bundle entry and client in the archive', () => { - for (const id of ['dsh-side-chat', 'dsh-annotation']) { + for (const id of ['dsh-side-chat', 'dsh-message-edit', 'dsh-annotation']) { const selected = selectPackage(id) const manifest = { name: selected.name, version: '0.1.0', repository: { url: 'git+https://github.com/ECNU/EduWork.git', directory: selected.directory }, dsh: { bundle: { patch: './cordis.patch.yml' } } } const files = Object.fromEntries(['lib/index.js', 'lib/client.js', 'cordis.patch.yml', 'README.md', 'README_EN.md', 'LICENSE'].map(file => ['package/' + file, 'fixture']))