feat(reasoning): persist opt-in machine translations
This commit is contained in:
1 parent
bf1c4e01ba
commit
cfc6be809c
116 files changed
+4506
-117
No files matched your search
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 2ba6c43b20a3d347
|
||||
zh: 5647179ddaa38ad9
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
|
||||
en: 1ec96ad7b624934f
|
||||
zh: ea73864d466874d2
|
||||
en: 4dfb3667c4dfe772
|
||||
zh: f97f2d0733c02d46
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered:
|
||||
en: a65d84004e48b3ef
|
||||
zh: 17221f08e7a8a89b
|
||||
|
||||
+2
-2
@@ -10,9 +10,9 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A
|
||||
|
||||
## Decision
|
||||
|
||||
`OPTIONAL_BUNDLES` lists Agent Teams, voice input, Auto review, the Inspector profile, and Git Worktrees. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector profile owns the package-named bundle patch. The standalone Inspector's `cordis.patch.yml` mounts its local built entry as an explicit repository overlay; [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the inspection behavior and activation choices.
|
||||
`OPTIONAL_BUNDLES` lists Agent Teams, voice input, reasoning translation, Auto review, the Inspector profile, and Git Worktrees. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector profile owns the package-named bundle patch. The standalone Inspector's `cordis.patch.yml` mounts its local built entry as an explicit repository overlay; [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the inspection behavior and activation choices.
|
||||
|
||||
An optional bundle is a runtime dependency of the installation, so its dependency graph is downloaded by every `dsh` install. The list weighs the added installation cost: Auto review reuses the installation's closure, while the Inspector profile adds inspection packages, `serve-static`/`open`/`ws`, and 535 mirrored DevTools resources (about 11.95 MiB uncompressed), without another browser binary. The Inspector profile appears in the default plugin list but remains disabled until selected. The browser-use and computer-use providers stay explicit compositions: Playwright MCP, Chrome DevTools MCP, and the native Cua Driver runtime binaries add about 85 MB and 21 packages to every install whether or not the bundle is enabled, and a shipped Cua Driver MCP switch would offer a capability whose executable the installation does not carry. Those provider packages keep locale display metadata for their component rows. Two more packages stay out for other reasons: `ptc-runtime-python` replaces the PTC runtime, and `workflow-ptc` rejects a non-TypeScript runtime at load while the Web presets carry `workflow-ptc` rows a bundle patch cannot reach; `browser-use-stagehand-native` requires a native model name and API key at schema validation with no configuration form on the page.
|
||||
An optional bundle is a runtime dependency of the installation, so its dependency graph is downloaded by every `dsh` install. The list weighs the added installation cost: Auto review reuses the installation's closure, while the Inspector profile adds inspection packages, `serve-static`/`open`/`ws`, and 535 mirrored DevTools resources (about 11.95 MiB uncompressed), without another browser binary. [Reasoning translation](2026-10-05-anonymous-reasoning-translation.md) adds three experimental packages without new third-party runtime dependencies. The Inspector profile appears in the default plugin list but remains disabled until selected. The browser-use and computer-use providers stay explicit compositions: Playwright MCP, Chrome DevTools MCP, and the native Cua Driver runtime binaries add about 85 MB and 21 packages to every install whether or not the bundle is enabled, and a shipped Cua Driver MCP switch would offer a capability whose executable the installation does not carry. Those provider packages keep locale display metadata for their component rows. Two more packages stay out for other reasons: `ptc-runtime-python` replaces the PTC runtime, and `workflow-ptc` rejects a non-TypeScript runtime at load while the Web presets carry `workflow-ptc` rows a bundle patch cannot reach; `browser-use-stagehand-native` requires a native model name and API key at schema validation with no configuration form on the page.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+2
-2
@@ -10,9 +10,9 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
|
||||
|
||||
## 决策
|
||||
|
||||
`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入、Auto review、Inspector profile 与 Git 工作树。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector profile 管理按包名挂载的 bundle patch。独立 Inspector 的 `cordis.patch.yml` 通过本地构建入口提供显式仓库 overlay;[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理检查行为与启用选择。
|
||||
`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入、思考过程翻译、Auto review、Inspector profile 与 Git 工作树。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector profile 管理按包名挂载的 bundle patch。独立 Inspector 的 `cordis.patch.yml` 通过本地构建入口提供显式仓库 overlay;[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理检查行为与启用选择。
|
||||
|
||||
可选 bundle 是安装的运行时依赖,其依赖图会随每次 `dsh` 安装一起下载。名单权衡新增安装成本:Auto review 复用安装闭包,Inspector profile 增加检查包、`serve-static`/`open`/`ws` 和 535 个镜像 DevTools 资源(未压缩约 11.95 MiB),但不携带另一个浏览器二进制。Inspector profile 在默认插件列表显示,选中前保持关闭。浏览器操作与电脑操作提供方仍保持显式组合:Playwright MCP、Chrome DevTools MCP 与原生 Cua Driver 运行时二进制会让每次安装多出约 85 MB、21 个包,无论 bundle 是否启用;而随附的 Cua Driver MCP 开关会提供一个安装本身并不携带其可执行文件的能力。这些提供方包保留 locale 显示元数据,供组件行使用。另有两个包因其他原因不纳入:`ptc-runtime-python` 会替换 PTC 运行时,而 `workflow-ptc` 在加载时拒绝非 TypeScript 运行时,且 Web preset 内含 bundle patch 无法触及的 `workflow-ptc` 行;`browser-use-stagehand-native` 在 schema 校验时就要求原生模型名称与 API 密钥,而插件页没有对应的配置表单。
|
||||
可选 bundle 是安装的运行时依赖,其依赖图会随每次 `dsh` 安装一起下载。名单权衡新增安装成本:Auto review 复用安装闭包,Inspector profile 增加检查包、`serve-static`/`open`/`ws` 和 535 个镜像 DevTools 资源(未压缩约 11.95 MiB),但不携带另一个浏览器二进制。[思考过程翻译](2026-10-05-anonymous-reasoning-translation.zh.md) 增加三个实验包,不增加第三方运行时依赖。Inspector profile 在默认插件列表显示,选中前保持关闭。浏览器操作与电脑操作提供方仍保持显式组合:Playwright MCP、Chrome DevTools MCP 与原生 Cua Driver 运行时二进制会让每次安装多出约 85 MB、21 个包,无论 bundle 是否启用;而随附的 Cua Driver MCP 开关会提供一个安装本身并不携带其可执行文件的能力。这些提供方包保留 locale 显示元数据,供组件行使用。另有两个包因其他原因不纳入:`ptc-runtime-python` 会替换 PTC 运行时,而 `workflow-ptc` 在加载时拒绝非 TypeScript 运行时,且 Web preset 内含 bundle patch 无法触及的 `workflow-ptc` 行;`browser-use-stagehand-native` 在 schema 校验时就要求原生模型名称与 API 密钥,而插件页没有对应的配置表单。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-05-anonymous-reasoning-translation.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.md
|
||||
/agent-note-anonymous-translation-remains-separate-from-model-history:
|
||||
en: ab8fee99b1f6d828
|
||||
zh: 0e324a42eebc1403
|
||||
/agent-note-anonymous-translation-remains-separate-from-model-history/problem:
|
||||
en: 5e10347fa87f98b5
|
||||
zh: cf3569631af8d66f
|
||||
/agent-note-anonymous-translation-remains-separate-from-model-history/decision:
|
||||
en: f13c134f53ed63c9
|
||||
zh: 868aacd453d26c82
|
||||
/agent-note-anonymous-translation-remains-separate-from-model-history/alternatives-considered:
|
||||
en: 083d50f8564b0378
|
||||
zh: 50af332e64ad06f3
|
||||
/agent-note-anonymous-translation-remains-separate-from-model-history/consequences:
|
||||
en: 57c774c6fb14a1b4
|
||||
zh: 5a05393e6f1d25b1
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Anonymous translation remains separate from model history
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-05-anonymous-reasoning-translation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Readers need reasoning in their chosen language without another model credential or replacement of the original assistant output. Translation needs an owner for external requests, cancellation, and failures independent of Agent execution.
|
||||
|
||||
## Decision
|
||||
|
||||
The experimental translator owns explicit Google or Bing routing, provider-language normalization, and bounded network requests. Its Service Definition and both providers share one package because their current consumers use the same operation and lifetime. The optional GUI consumer owns settings, paragraph scheduling, per-disclosure caches, and translated display. Its authenticated Remote calls the translator; the agent loop and Session event types remain independent.
|
||||
|
||||
Bing is the configured default. Enabling the bundle permits transmission of expanded reasoning to the chosen service, disclosed in plugin metadata and settings. Failure never selects another provider. Original remains available; translated text neither replaces persisted assistant output nor enters model input. The [optional-bundle admission policy](2026-09-21-experimental-capabilities-as-optional-bundles.md) keeps the feature disabled until selected.
|
||||
|
||||
The reasoning-body chain carries original text and streaming status. Native Chat owns its original Markdown fallback; consumers do not replace the assistant renderer or alter Session projection. Only expanded bodies start translation. Collapsing, changing selection, or unloading aborts outstanding work and excludes late results.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Translate through the conversation's LLM provider.** This adds model requests and depends on model credentials, pricing, and routing. Anonymous translation has separate availability and requires none of those credentials.
|
||||
|
||||
**Rewrite reasoning in the Session or provider stream.** This changes the original trajectory and can affect model reasoning passback. Display translation preserves the original model history.
|
||||
|
||||
**Silently try another endpoint after failure.** This sends potentially private text to another operator without an explicit selection. Retry uses the same provider; switching providers is a user action.
|
||||
|
||||
## Consequences
|
||||
|
||||
Anonymous translation adds no third-party translation library or model-token usage. Anonymous endpoints provide no developer availability guarantee. Both providers send source text in POST request bodies. Translations may alter Markdown or code formatting and do not translate collapsed summaries.
|
||||
|
||||
The translator retains Session-bound results under the [shared machine-translation persistence decision](2026-10-05-persistent-machine-translation.md); its stateless calls do not record results.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 免登录翻译独立于模型历史
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-05-anonymous-reasoning-translation.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
读者需要用指定语言阅读思考内容,同时无需额外模型凭据,也不替换原始 assistant 输出。翻译需要独立于 Agent 执行的所有者,负责外部请求、取消与失败。
|
||||
|
||||
## 决策
|
||||
|
||||
实验性 translator 负责显式 Google 或 Bing 路由、Provider 语言标签映射及有界网络请求。Service Definition 与两种 Provider 共用一个包,因为当前消费者使用相同操作与生命周期。可选 GUI 消费者负责设置、段落调度、展开组件内缓存及译文展示。其经过身份验证的 Remote 调用 translator;agent loop 与 Session 事件类型保持独立。
|
||||
|
||||
配置的默认服务为 Bing。启用 Bundle 允许将展开的思考内容发送给所选服务,插件元数据与设置公开说明接收方。失败时不选择其他 Provider。原文始终可访问;译文不替换持久化 assistant 输出,也不进入模型输入。[可选 Bundle 准入规则](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md) 保证功能在显式选择前保持关闭。
|
||||
|
||||
reasoning-body chain 传递原始文本与流式状态。原生 Chat 负责原始 Markdown fallback;消费者不替换 assistant 渲染器,也不改变 Session projection。只有展开内容才开始翻译。折叠、切换设置或卸载都会中止未完成工作并忽略迟到结果。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**通过对话使用的 LLM Provider 翻译。** 这会增加模型请求,并依赖模型凭据、定价与路由。免登录翻译有独立的可用性,且无需这些凭据。
|
||||
|
||||
**重写 Session 或 Provider 流中的思考内容。** 这会改变原始轨迹,并可能影响思考内容回传。展示翻译保留原始模型历史。
|
||||
|
||||
**失败后静默尝试另一端点。** 这会在没有显式选择的情况下将潜在私有文本发送给另一个运营方。重试使用同一 Provider;切换 Provider 是用户操作。
|
||||
|
||||
## 后果
|
||||
|
||||
匿名翻译不增加第三方翻译库或模型 token 消耗。免登录端点不保证开发者可用性。两种服务均通过 POST 请求体发送原文。译文可能改变 Markdown 或代码格式,也不翻译折叠摘要。
|
||||
|
||||
translator 按[统一的机器翻译持久化决策](2026-10-05-persistent-machine-translation.zh.md)保留绑定 Session 的结果;无状态调用不记录结果。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-05-persistent-machine-translation.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-05-persistent-machine-translation.md
|
||||
/agent-note-machine-translation-results-use-shared-session-records:
|
||||
en: 0587db1f8b7b3c6d
|
||||
zh: 63af01050024d4d1
|
||||
/agent-note-machine-translation-results-use-shared-session-records/problem:
|
||||
en: 4194ba9786778876
|
||||
zh: 6b99613a4d0dd5d2
|
||||
/agent-note-machine-translation-results-use-shared-session-records/decision:
|
||||
en: fb8800dede33177b
|
||||
zh: b3f5098e54d5d982
|
||||
/agent-note-machine-translation-results-use-shared-session-records/alternatives-considered:
|
||||
en: 3f251877332d74cc
|
||||
zh: 61991d5fd4704a99
|
||||
/agent-note-machine-translation-results-use-shared-session-records/consequences:
|
||||
en: c51bbb4ec6b7f20b
|
||||
zh: 856dbfdf47509d90
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Machine translation results use shared Session records
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-05-persistent-machine-translation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Readers need a saved translation when they reopen reasoning or restart the application. A disclosure-only cache repeats external requests, can change an already displayed result and can incur repeated model charges. Storing only model inputs leaves successful translations unavailable after restart.
|
||||
|
||||
## Decision
|
||||
|
||||
The translator owns `plugin:translator/request` and `plugin:translator/result` for every provider. A request records its selected provider, exact source text, source and target languages, and recipe identity. Optional metadata carries provider-owned request details. A result references the request's Session sequence and retains the completed translated text. Requests are durable before dispatch, and results are durable before they return to the GUI.
|
||||
|
||||
The browser binds displayed translations to the disclosure's Session, and the Host validates the configured provider and explicit target language. The authenticated Client supplies the original text and Session identity; the Host does not verify that the text belongs to that Session. Successful records are reusable across disclosure lifetimes and restarts. Identity includes the provider and recipe, so changing the receiver, language or translation settings cannot substitute another result. Identical concurrent translations wait for one result; distinct fragments can run in parallel.
|
||||
|
||||
Read-only lookup precedes provider work. New records use the active Session's existing writer through `appendPluginRecord` and targeted flush. An uncached inactive Session rejects before provider work; the GUI consumer joins normal Session controller activation and retries once.
|
||||
|
||||
These records accompany the [original reasoning](2026-10-05-anonymous-reasoning-translation.md) and never replace its events or model input. They use the existing experimental record writer and best-effort format-migration retention. Provider availability gates new requests; a saved result remains readable without another inference. Stateless translator calls remain available without a Session.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep only disclosure-local results.** Reopening loses the displayed value and sends the same text again, with possible repeat charges.
|
||||
|
||||
**Give anonymous and model translations different result formats.** The reader needs the same source-to-translation relationship from every provider. Provider-owned audit details belong in optional metadata.
|
||||
|
||||
**Replace original assistant reasoning.** A translation is derived data. Keeping it alongside the original preserves model history and access to the source.
|
||||
|
||||
**Open a separate writer for historical translations.** This competes with normal Session activation and requires coordination between two writer owners. The GUI already activates opened conversations; using its existing controller keeps that ownership in one place.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Session retains translated text and request identity in addition to original reasoning. Only completed persisted results supply cached text; failed or incomplete attempts do not. A completion that has already entered the append-only log may survive a late caller cancellation. Records do not introduce released Session schema history, and future format migrations may drop experimental state.
|
||||
|
||||
Saved results remain readable without activation. A new translation may wait for normal Session activation, including preset startup hooks, resume markers, interrupted-turn repair, and any required generation migration. These ordinary lifecycle effects can append context; translation does not bypass them. Cache reads leave older generations unpublished. The translator owns no second writer and requires no core lifecycle changes.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 机器翻译结果使用统一的 Session 记录
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-05-persistent-machine-translation.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
读者在重新展开思考内容或重启应用后仍需要已保存的译文。只在展开区域缓存会重复外部请求、可能改变已显示结果,也可能重复产生模型费用。仅存储模型输入无法在重启后恢复成功译文。
|
||||
|
||||
## 决策
|
||||
|
||||
translator 对所有 Provider 统一使用 `plugin:translator/request` 和 `plugin:translator/result`。请求记录所选 Provider、精确原文、源语言和目标语言,以及翻译规则标识。可选 metadata 保存 Provider 自有请求细节。结果引用请求的 Session 序号并保留完成后的译文。请求在派发前持久化,结果在返回 GUI 前持久化。
|
||||
|
||||
浏览器将展示翻译绑定到展开区域的 Session,Host 校验配置的提供方和显式目标语言。已认证的 Client 提供原文与 Session 身份;Host 不校验原文是否属于该 Session。成功记录可跨展开组件生命周期和重启复用。身份包含 Provider 和翻译规则,因此改变接收方、语言或翻译设置不会混用其他结果。相同的并发翻译等待同一结果,不同片段可并行执行。
|
||||
|
||||
只读查找先于 Provider 调用。新记录通过已激活 Session 的现有写入器调用 `appendPluginRecord` 并定向刷新。未激活 Session 缓存未命中时,在 Provider 调用前拒绝;GUI 消费者等待 Session controller 的正常激活,再重试一次。
|
||||
|
||||
这些记录与[原始思考内容](2026-10-05-anonymous-reasoning-translation.zh.md)一同保留,从不替换其事件或模型输入。它们使用现有的实验性记录写入器,格式迁移采用尽力保留语义。Provider 可用性限制新请求,已保存结果无需再次推理即可读取。不指定 Session 时,translator 仍支持无状态调用。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只保留展开区域内的结果。** 重新展开会丢失已展示值并再次发送同样的文本,可能重复计费。
|
||||
|
||||
**匿名翻译与模型翻译使用不同的结果格式。** 读者对所有 Provider 都需要相同的原文到译文关系。Provider 自有审计细节属于可选 metadata。
|
||||
|
||||
**替换原始 assistant 思考内容。** 翻译属于派生数据。与原文并存可以保留模型历史和原文访问能力。
|
||||
|
||||
**为历史翻译另开写入器。** 这会与正常 Session 激活竞争,并需要协调两个写入所有者。GUI 已经会激活打开的对话;使用其现有 controller 可将所有权集中在一处。
|
||||
|
||||
## 后果
|
||||
|
||||
Session 在原始思考内容之外保留译文和请求身份。只有完成并持久化的结果提供缓存译文,失败或未完成尝试不提供。已进入 append-only 日志的完成记录可能在调用者迟到取消后保留。记录不引入已发布的 Session schema 历史,未来格式迁移可能丢弃实验性状态。
|
||||
|
||||
已保存结果无需激活即可读取。新翻译可能等待正常 Session 激活,包括 preset 启动钩子、恢复标记、中断轮次修复及所需的 generation 迁移。这些普通生命周期行为可能追加上下文;翻译不会绕过它们。缓存读取不会发布旧 generation。translator 不拥有第二个写入器,也不需要修改核心生命周期。
|
||||
@@ -103,6 +103,7 @@
|
||||
"@deepseek-ai/dsh-agent-preset": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-auto-review": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-cot-translation-bundle": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"@deepseek-ai/dsh-experimental-auto-review": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-speech-to-text": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-cot-translation-bundle": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-worktree": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/** Durable reasoning translation through a real optional profile and recorded model Session. */
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { pluginRecordOf, SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { chromium, type Browser } from 'playwright'
|
||||
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, readPersistedEvents, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspaceZh, expandOwningTurnProcess, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const BUNDLE_NAME = '@deepseek-ai/dsh-experimental-cot-translation-bundle'
|
||||
const BUNDLE = fileURLToPath(new URL('../../../packages/experimental/cot-translation-bundle', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v4.jsonl', import.meta.url))
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/cot-translation', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const ORIGINAL = 'The user wants me to reply with a single word. Let me comply.'
|
||||
const GOOGLE_TRANSLATION = '用户让我只回复一个词。我会照做。'
|
||||
const BING_TRANSLATION = '用户要求我回复一个单词。'
|
||||
const JAPANESE_TRANSLATION = 'ユーザーは単語一つでの返答を求めています。'
|
||||
const GERMAN_TRANSLATION = 'Der Nutzer möchte eine Antwort mit einem einzigen Wort.'
|
||||
|
||||
/** Translation records are the only additions permitted beside the recorded conversation. */
|
||||
function isTranslationRecord(event: SessionEvent): boolean {
|
||||
const type = pluginRecordOf(event)?.type
|
||||
return type === 'plugin:translator/request' || type === 'plugin:translator/result'
|
||||
}
|
||||
|
||||
interface TranslationCall {
|
||||
provider: 'google' | 'bing'
|
||||
text: string
|
||||
target: string
|
||||
hasAuthorization: boolean
|
||||
hasCookie: boolean
|
||||
}
|
||||
|
||||
interface PendingTranslation {
|
||||
response: ServerResponse
|
||||
closed: boolean
|
||||
settled: Promise<undefined>
|
||||
}
|
||||
|
||||
/** Read an external provider's request body without intercepting Host or model traffic. */
|
||||
async function requestBody(request: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk as Uint8Array))
|
||||
return Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen and browser reload, changes provider and language, retains originals on failure and cancels uncached requests', async () => {
|
||||
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cot-translation-'))
|
||||
const calls: TranslationCall[] = []
|
||||
const fixtureErrors: unknown[] = []
|
||||
const requests: Promise<void>[] = []
|
||||
const heldResponses = new Set<ServerResponse>()
|
||||
const pendingTranslation = Promise.withResolvers<PendingTranslation | undefined>()
|
||||
let fail = false
|
||||
let holdNextResponse = false
|
||||
async function respond(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
const url = new URL(request.url ?? '/', 'http://translation.test')
|
||||
const provider = url.pathname === '/google' ? 'google' : 'bing'
|
||||
let text: string
|
||||
let target: string
|
||||
if (provider === 'google') {
|
||||
expect(request.method).toBe('POST')
|
||||
expect(request.headers['content-type']).toMatch(/^application\/x-www-form-urlencoded(?:;|$)/)
|
||||
expect(url.searchParams.has('q')).toBe(false)
|
||||
const body = new URLSearchParams(await requestBody(request))
|
||||
expect(body.get('client')).toBe('gtx')
|
||||
expect(body.get('sl')).toBe('auto')
|
||||
expect(body.get('dt')).toBe('t')
|
||||
text = body.get('q') ?? ''
|
||||
target = body.get('tl') ?? ''
|
||||
} else {
|
||||
expect(url.pathname).toBe('/bing')
|
||||
expect(request.method).toBe('POST')
|
||||
expect(url.searchParams.get('isEnterpriseClient')).toBe('false')
|
||||
expect(url.searchParams.has('from')).toBe(false)
|
||||
const body: unknown = JSON.parse(await requestBody(request))
|
||||
if (!Array.isArray(body) || body.length !== 1 || typeof body[0] !== 'string') {
|
||||
throw new Error('Bing must receive exactly one text string')
|
||||
}
|
||||
text = body[0]
|
||||
target = url.searchParams.get('to') ?? ''
|
||||
}
|
||||
calls.push({ provider, text, target,
|
||||
hasAuthorization: request.headers.authorization !== undefined, hasCookie: request.headers.cookie !== undefined })
|
||||
if (holdNextResponse) {
|
||||
holdNextResponse = false
|
||||
const closed = Promise.withResolvers<undefined>()
|
||||
const pending: PendingTranslation = { response, closed: false, settled: closed.promise }
|
||||
heldResponses.add(response)
|
||||
response.once('close', () => {
|
||||
pending.closed = true
|
||||
heldResponses.delete(response)
|
||||
closed.resolve(undefined)
|
||||
})
|
||||
pendingTranslation.resolve(pending)
|
||||
await pending.settled
|
||||
return
|
||||
}
|
||||
if (fail) { response.writeHead(503); response.end(); return }
|
||||
const translated = target === 'ja' ? JAPANESE_TRANSLATION : target === 'de' ? GERMAN_TRANSLATION
|
||||
: provider === 'google' ? GOOGLE_TRANSLATION : BING_TRANSLATION
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify(provider === 'google'
|
||||
? [[[translated, text, null, null]], null, 'en']
|
||||
: [{ translations: [{ text: translated, to: target }] }]))
|
||||
}
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(respond(request, response).catch((error: unknown) => {
|
||||
fixtureErrors.push(error)
|
||||
if (!response.destroyed) { response.writeHead(500); response.end() }
|
||||
}))
|
||||
})
|
||||
onTestFinished(async () => {
|
||||
const failures: unknown[] = []
|
||||
pendingTranslation.resolve(undefined)
|
||||
for (const response of heldResponses) response.destroy()
|
||||
await resources.browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await resources.scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
const closed = new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => { if (error) reject(error); else resolve() })
|
||||
})
|
||||
server.closeAllConnections()
|
||||
await closed.catch((error: unknown) => failures.push(error))
|
||||
await Promise.all(requests).catch((error: unknown) => failures.push(error))
|
||||
await rm(root, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'CoT translation teardown failed')
|
||||
})
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('Expected an allocated loopback listener')
|
||||
const origin = `http://127.0.0.1:${address.port}`
|
||||
const overlay = join(root, 'translator.patch.yml')
|
||||
await writeFile(overlay, `- id: translator\n config: ${JSON.stringify({
|
||||
googleEndpoint: `${origin}/google`, bingEndpoint: `${origin}/bing`,
|
||||
// The provider deadline must exceed the cancellation observation's 10-second bound.
|
||||
timeoutMs: 120_000,
|
||||
})}\n`)
|
||||
const scaffold = await launchWebScaffold({
|
||||
profile: { packages: [{ dir: BUNDLE }] },
|
||||
extraOverlayPath: overlay,
|
||||
replayFixture: FIXTURE,
|
||||
// Translation records are asserted separately from the unchanged borrowed conversation.
|
||||
compareReplaySession: false,
|
||||
})
|
||||
resources.scaffold = scaffold
|
||||
const browser = await chromium.launch()
|
||||
resources.browser = browser
|
||||
const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai' })
|
||||
const page = await context.newPage()
|
||||
const settings = await context.newPage()
|
||||
const consoles = [watchConsole(page), watchConsole(settings)]
|
||||
onTestFailed(async () => {
|
||||
await saveFailureShot(page, 'web-e2e-cot-translation')
|
||||
await saveFailureShot(settings, 'web-e2e-cot-translation-settings')
|
||||
})
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
const prompts = fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))
|
||||
expect(prompts).toHaveLength(1)
|
||||
const prompt = prompts[0]
|
||||
if (prompt === undefined) throw new Error('Recorded translation scenario requires a user prompt')
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
await composer.fill(prompt)
|
||||
await composer.press('Enter')
|
||||
const sessionId = await settled
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
const reasoning = page.locator('[data-variant="think"]').first()
|
||||
await reasoning.waitFor({ state: 'attached' })
|
||||
await expandOwningTurnProcess(page, reasoning)
|
||||
await reasoning.locator('[data-disclosure-row]').first().click()
|
||||
await reasoning.getByText(ORIGINAL, { exact: true }).waitFor()
|
||||
expect(await reasoning.locator('[data-cot-translation]').count()).toBe(0)
|
||||
expect(calls).toEqual([])
|
||||
await reasoning.locator('[data-disclosure-row]').first().click()
|
||||
const recordedEvents = await readPersistedEvents(scaffold, sessionId)
|
||||
const session = scaffold.ctx.sessions.get(sessionId)
|
||||
if (session === undefined) throw new Error('Recorded reasoning requires its existing Session')
|
||||
const recordedHeader = (await scaffold.ctx.sessionPersistence.stat(sessionId))?.header
|
||||
if (recordedHeader === undefined) throw new Error('Recorded reasoning requires its durable Session header')
|
||||
const recordedModelHistory = session.deriveMessages(), recordedRequestHeader = session.requestHeader()
|
||||
|
||||
await settings.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await settings.getByRole('button', { name: '插件', exact: true }).click()
|
||||
const panel = settings.locator('[data-plugin-panel]')
|
||||
const card = panel.locator(`[data-plugin-package="${BUNDLE_NAME}"]`)
|
||||
const toggle = card.getByRole('switch')
|
||||
await toggle.waitFor()
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('false')
|
||||
await toggle.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
|
||||
await card.getByRole('button').first().click()
|
||||
const provider = panel.getByLabel('翻译服务', { exact: true })
|
||||
const target = panel.getByLabel('目标语言', { exact: true })
|
||||
await provider.waitFor()
|
||||
expect(await provider.inputValue()).toBe('bing')
|
||||
expect(await target.inputValue()).toBe('auto')
|
||||
await panel.getByText('展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。', { exact: true }).waitFor()
|
||||
await compareOrRefreshGolden(join(EXPECTED, 'preferences.expected.md'),
|
||||
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
|
||||
replacements: [[BUNDLE, '{{bundle}}']],
|
||||
}), MODE)
|
||||
expect(calls).toEqual([])
|
||||
await reasoning.locator('[data-disclosure-row]').first().click()
|
||||
const body = reasoning.locator('[data-cot-translation="true"]')
|
||||
await body.getByText(BING_TRANSLATION, { exact: true }).waitFor()
|
||||
await expect.poll(() => body.getAttribute('data-translation-state')).toBe('ready')
|
||||
expect(calls).toEqual([{ provider: 'bing', text: ORIGINAL, target: 'zh-Hans', hasAuthorization: false, hasCookie: false }])
|
||||
await compareOrRefreshGolden(join(EXPECTED, 'bing.expected.md'),
|
||||
await captureStableAria(page, '[data-cot-translation]', scaffold.workspaceCwd), MODE)
|
||||
await body.getByRole('button', { name: '原文', exact: true }).click()
|
||||
await body.getByText(ORIGINAL, { exact: true }).waitFor()
|
||||
expect(await body.getAttribute('data-translation-view')).toBe('original')
|
||||
await body.getByRole('button', { name: '译文', exact: true }).click()
|
||||
await body.getByText(BING_TRANSLATION, { exact: true }).waitFor()
|
||||
expect(calls).toHaveLength(1)
|
||||
const savedBing = await readPersistedEvents(scaffold, sessionId)
|
||||
expect(savedBing.filter(event => !isTranslationRecord(event))).toEqual(recordedEvents)
|
||||
const bingRecords = savedBing.filter(isTranslationRecord)
|
||||
expect(bingRecords).toHaveLength(2)
|
||||
expect(bingRecords[0]).toMatchObject({ type: 'plugin:translator/request', ignorable: true,
|
||||
data: { provider: 'bing', text: ORIGINAL, sourceLanguage: 'auto', targetLanguage: 'zh' } })
|
||||
const bingRequest = pluginRecordOf(bingRecords[0]!)?.data
|
||||
if (typeof bingRequest !== 'object' || bingRequest === null || !('recipe' in bingRequest)) {
|
||||
throw new Error('Translation request lacks its recipe identity')
|
||||
}
|
||||
expect(typeof bingRequest.recipe).toBe('string')
|
||||
expect(bingRecords[1]).toMatchObject({ type: 'plugin:translator/result', ignorable: true,
|
||||
data: { requestSeq: bingRecords[0]?.seq, text: BING_TRANSLATION } })
|
||||
|
||||
await reasoning.locator('[data-disclosure-row]').first().click()
|
||||
await body.waitFor({ state: 'detached' })
|
||||
await reasoning.locator('[data-disclosure-row]').first().click()
|
||||
await body.getByText(BING_TRANSLATION, { exact: true }).waitFor()
|
||||
await expect.poll(() => body.getAttribute('data-translation-state')).toBe('ready')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(await readPersistedEvents(scaffold, sessionId)).toEqual(savedBing)
|
||||
const warningStart = consoles[0]!.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
await reasoning.waitFor({ state: 'attached' })
|
||||
await expandOwningTurnProcess(page, reasoning)
|
||||
const disclosure = reasoning.locator('[data-disclosure-row]').first()
|
||||
if (await disclosure.getAttribute('aria-expanded') === 'false') await disclosure.click()
|
||||
await body.getByText(BING_TRANSLATION, { exact: true }).waitFor()
|
||||
await expect.poll(() => body.getAttribute('data-translation-state')).toBe('ready')
|
||||
acknowledgeReloadConnectionLoss(consoles[0]!, warningStart)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(await readPersistedEvents(scaffold, sessionId)).toEqual(savedBing)
|
||||
|
||||
await provider.selectOption('google')
|
||||
await target.fill('zh')
|
||||
expect(calls).toHaveLength(1)
|
||||
const save = panel.getByRole('button', { name: '保存', exact: true })
|
||||
await save.click()
|
||||
await expect.poll(() => save.isDisabled()).toBe(true)
|
||||
await body.getByText(GOOGLE_TRANSLATION, { exact: true }).waitFor()
|
||||
expect(calls[1]).toEqual({ provider: 'google', text: ORIGINAL, target: 'zh-CN', hasAuthorization: false, hasCookie: false })
|
||||
expect(calls).toHaveLength(2)
|
||||
|
||||
fail = true
|
||||
await target.fill('ja')
|
||||
await save.click()
|
||||
await expect.poll(() => save.isDisabled()).toBe(true)
|
||||
await expect.poll(() => body.getAttribute('data-translation-state')).toBe('failed')
|
||||
await body.getByText('翻译失败,已保留原文', { exact: true }).waitFor()
|
||||
await body.getByText(ORIGINAL, { exact: true }).waitFor()
|
||||
expect(await body.getAttribute('data-translation-view')).toBe('original')
|
||||
expect(calls[2]).toEqual({ provider: 'google', text: ORIGINAL, target: 'ja', hasAuthorization: false, hasCookie: false })
|
||||
expect(calls).toHaveLength(3)
|
||||
await compareOrRefreshGolden(join(EXPECTED, 'failure.expected.md'),
|
||||
await captureStableAria(page, '[data-cot-translation]', scaffold.workspaceCwd), MODE)
|
||||
fail = false
|
||||
await body.getByRole('button', { name: '重试', exact: true }).click()
|
||||
await body.getByText(JAPANESE_TRANSLATION, { exact: true }).waitFor()
|
||||
await expect.poll(() => body.getAttribute('data-translation-state')).toBe('ready')
|
||||
expect(calls).toHaveLength(4)
|
||||
expect(calls[3]).toEqual({ provider: 'google', text: ORIGINAL, target: 'ja', hasAuthorization: false, hasCookie: false })
|
||||
|
||||
await reasoning.locator('[data-disclosure-row]').first().click()
|
||||
await body.waitFor({ state: 'detached' })
|
||||
await target.fill('de')
|
||||
await save.click()
|
||||
await expect.poll(() => save.isDisabled()).toBe(true)
|
||||
holdNextResponse = true
|
||||
await reasoning.locator('[data-disclosure-row]').first().click()
|
||||
await expect.poll(() => heldResponses.size, { timeout: 10_000 }).toBe(1)
|
||||
const pending = await pendingTranslation.promise
|
||||
if (pending === undefined) throw new Error('Translation fixture ended before receiving the held request')
|
||||
await expect.poll(() => body.getAttribute('data-translation-state')).toBe('pending')
|
||||
expect(calls).toHaveLength(5)
|
||||
expect(calls[4]).toEqual({ provider: 'google', text: ORIGINAL, target: 'de', hasAuthorization: false, hasCookie: false })
|
||||
expect(pending.response.headersSent).toBe(false)
|
||||
await reasoning.locator('[data-disclosure-row]').first().click()
|
||||
await body.waitFor({ state: 'detached' })
|
||||
await expect.poll(() => pending.closed, { timeout: 10_000 }).toBe(true)
|
||||
await pending.settled
|
||||
expect(pending.response.headersSent).toBe(false)
|
||||
expect(pending.response.writableFinished).toBe(false)
|
||||
expect(await reasoning.getByText(JAPANESE_TRANSLATION, { exact: true }).count()).toBe(0)
|
||||
await reasoning.locator('[data-disclosure-row]').first().click()
|
||||
await body.getByText(GERMAN_TRANSLATION, { exact: true }).waitFor()
|
||||
await expect.poll(() => body.getAttribute('data-translation-state')).toBe('ready')
|
||||
expect(calls).toHaveLength(6)
|
||||
|
||||
await panel.getByRole('button', { name: '返回插件列表', exact: true }).click()
|
||||
await toggle.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
|
||||
await body.waitFor({ state: 'detached' })
|
||||
await reasoning.getByText(ORIGINAL, { exact: true }).waitFor()
|
||||
await reasoning.locator('[data-disclosure-row]').first().click()
|
||||
await reasoning.locator('[data-disclosure-row]').first().click()
|
||||
await reasoning.getByText(ORIGINAL, { exact: true }).waitFor()
|
||||
expect(calls).toHaveLength(6)
|
||||
const completedEvents = await readPersistedEvents(scaffold, sessionId)
|
||||
expect(completedEvents.filter(event => !isTranslationRecord(event))).toEqual(recordedEvents)
|
||||
expect((await scaffold.ctx.sessionPersistence.stat(sessionId))?.header).toEqual(recordedHeader)
|
||||
expect(session.deriveMessages()).toEqual(recordedModelHistory)
|
||||
expect(session.requestHeader()).toEqual(recordedRequestHeader)
|
||||
const translationRecords = completedEvents.filter(isTranslationRecord)
|
||||
const requestsBySeq = new Map(translationRecords.flatMap(event => pluginRecordOf(event)?.type === 'plugin:translator/request' ? [[event.seq, event] as const] : []))
|
||||
const results = translationRecords.filter(event => pluginRecordOf(event)?.type === 'plugin:translator/result')
|
||||
expect(requestsBySeq.size).toBe(6)
|
||||
expect(results).toHaveLength(4)
|
||||
expect(results.map(event => event.data)).toEqual([
|
||||
{ requestSeq: translationRecords[0]?.seq, text: BING_TRANSLATION },
|
||||
{ requestSeq: translationRecords[2]?.seq, text: GOOGLE_TRANSLATION },
|
||||
{ requestSeq: translationRecords[5]?.seq, text: JAPANESE_TRANSLATION },
|
||||
{ requestSeq: translationRecords[8]?.seq, text: GERMAN_TRANSLATION },
|
||||
])
|
||||
for (const result of results) {
|
||||
const data = pluginRecordOf(result)?.data
|
||||
if (typeof data !== 'object' || data === null || !('requestSeq' in data) || typeof data.requestSeq !== 'number') {
|
||||
throw new Error('Translation result lacks its durable request sequence')
|
||||
}
|
||||
const request = requestsBySeq.get(SessionSeq(data.requestSeq))
|
||||
expect(request?.seq).toBeLessThan(result.seq)
|
||||
}
|
||||
const modelLog = JSON.stringify(recordedModelHistory)
|
||||
for (const translated of [GOOGLE_TRANSLATION, BING_TRANSLATION, JAPANESE_TRANSLATION, GERMAN_TRANSLATION]) {
|
||||
expect(modelLog).not.toContain(translated)
|
||||
}
|
||||
expect(fixtureErrors).toEqual([])
|
||||
for (const console of consoles) {
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
}
|
||||
await assertFixtureInventory(EXPECTED, ['bing.expected.md', 'preferences.expected.md', 'failure.expected.md'])
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
- button "译文" [pressed]
|
||||
- button "原文"
|
||||
- paragraph: 用户要求我回复一个单词。
|
||||
@@ -0,0 +1,5 @@
|
||||
- button "译文" [disabled]
|
||||
- button "原文" [pressed]
|
||||
- status: 翻译失败,已保留原文
|
||||
- button "重试"
|
||||
- paragraph: The user wants me to reply with a single word. Let me comply.
|
||||
@@ -0,0 +1,38 @@
|
||||
- button "返回插件列表": 插件列表
|
||||
- button "卸载 思考过程机器翻译": 卸载
|
||||
- switch "启用 思考过程机器翻译" [checked]
|
||||
- heading "思考过程机器翻译" [level=3]
|
||||
- text: v0.2.1-alpha.1 实验性
|
||||
- paragraph:
|
||||
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
|
||||
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
|
||||
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
|
||||
- text: 翻译服务
|
||||
- combobox "翻译服务":
|
||||
- option "Bing" [selected]
|
||||
- option "Google"
|
||||
- text: 目标语言
|
||||
- textbox "目标语言":
|
||||
- /placeholder: ""
|
||||
- text: auto
|
||||
- paragraph: auto 使用界面语言;也可填写语言代码,例如 zh、en、ja
|
||||
- button "保存" [disabled]
|
||||
- heading "包含的组件" [level=4]
|
||||
- text: 共 2 个 · 2 运行中
|
||||
- list:
|
||||
- listitem:
|
||||
- text: "@deepseek-ai/dsh-experimental-translator Machine translation with reusable Session results"
|
||||
- code: translator
|
||||
- text: 运行中
|
||||
- switch "启用组件 @deepseek-ai/dsh-experimental-translator" [checked]
|
||||
- listitem:
|
||||
- text: 思考过程机器翻译 将展开的思考内容机器翻译为其他语言。
|
||||
- code: cot-translation
|
||||
- code: "@deepseek-ai/dsh-experimental-client-ui-cot-translation"
|
||||
- text: 运行中
|
||||
- switch "启用组件 思考过程机器翻译" [checked]
|
||||
- heading "来源信息" [level=4]
|
||||
- term: 代码来源
|
||||
- definition: 内置
|
||||
- term: 当前版本
|
||||
- definition: 0.2.1-alpha.1
|
||||
@@ -6,7 +6,7 @@
|
||||
- button "添加插件"
|
||||
- button "选择添加插件方式"
|
||||
- heading "官方" [level=3]
|
||||
- text: "9"
|
||||
- text: "10"
|
||||
- list:
|
||||
- listitem:
|
||||
- button "查看 智能体团队": 智能体团队
|
||||
@@ -16,6 +16,10 @@
|
||||
- button "查看 自动授权审查": 自动授权审查
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 思考过程机器翻译": 思考过程机器翻译
|
||||
- text: 实验性 机器翻译展开的思考内容;原文会发送给所选服务
|
||||
- switch "启用 思考过程机器翻译"
|
||||
- listitem:
|
||||
- button "查看 开发者工具": 开发者工具
|
||||
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- button "添加插件"
|
||||
- button "选择添加插件方式"
|
||||
- heading "官方" [level=3]
|
||||
- text: "9"
|
||||
- text: "10"
|
||||
- list:
|
||||
- listitem:
|
||||
- button "查看 智能体团队": 智能体团队
|
||||
@@ -16,6 +16,10 @@
|
||||
- button "查看 自动授权审查": 自动授权审查
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 思考过程机器翻译": 思考过程机器翻译
|
||||
- text: 实验性 机器翻译展开的思考内容;原文会发送给所选服务
|
||||
- switch "启用 思考过程机器翻译"
|
||||
- listitem:
|
||||
- button "查看 开发者工具": 开发者工具
|
||||
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- button "添加插件"
|
||||
- button "选择添加插件方式"
|
||||
- heading "官方" [level=3]
|
||||
- text: "9"
|
||||
- text: "10"
|
||||
- list:
|
||||
- listitem:
|
||||
- button "查看 智能体团队": 智能体团队
|
||||
@@ -16,6 +16,10 @@
|
||||
- button "查看 自动授权审查": 自动授权审查
|
||||
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
|
||||
- switch "启用 自动授权审查"
|
||||
- listitem:
|
||||
- button "查看 思考过程机器翻译": 思考过程机器翻译
|
||||
- text: 实验性 机器翻译展开的思考内容;原文会发送给所选服务
|
||||
- switch "启用 思考过程机器翻译"
|
||||
- listitem:
|
||||
- button "查看 开发者工具": 开发者工具
|
||||
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
|
||||
|
||||
@@ -6,7 +6,7 @@ Focus at 500ms:
|
||||
- tooltip "刷新"
|
||||
failure: pending
|
||||
- button "刷新" [disabled]
|
||||
{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
|
||||
{"busy":"true","spinners":1,"cards":13,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
|
||||
failure: settled
|
||||
- button "刷新"
|
||||
{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
|
||||
{"busy":"false","spinners":0,"cards":13,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
|
||||
@@ -186,9 +186,9 @@ describe('web e2e: plugin manager', () => {
|
||||
await official.locator('[data-plugin-package]').first().waitFor()
|
||||
// The Official group lists the shipped bundles and then the
|
||||
// configuration-only items; an item card carries no action cell, so the
|
||||
// placeholder rows model the bundle cards alone.
|
||||
const loaded = await measure(official, ':scope > ul > li[data-plugin-package]')
|
||||
// placeholder rows model the first four bundle cards.
|
||||
expect(await official.locator('[data-plugin-package]').count()).toBe(OPTIONAL_BUNDLES.length)
|
||||
const loaded = await measure(official, ':scope > ul > li[data-plugin-package]')
|
||||
expect(loaded.rows).toHaveLength(loading.rows.length)
|
||||
const compare = (name: string, a: typeof loading.pageHeader, b: typeof loaded.pageHeader, axes: readonly (keyof typeof a)[] = ['x', 'y', 'width', 'height']) => {
|
||||
for (const axis of axes) {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"tests/connection-recovery.e2e.ts",
|
||||
"tests/server-restart.e2e.ts",
|
||||
"tests/voice-input.e2e.ts",
|
||||
"tests/cot-translation.e2e.ts",
|
||||
"tests/voice-setup.e2e.ts",
|
||||
"tests/voice-download.e2e.ts",
|
||||
"tests/prefix-proxy.ts",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: 41d772192075e133
|
||||
zh: 5ef3da69b4de1d8a
|
||||
/capability-seams-and-core-services:
|
||||
en: 0241d318a0b3fd8d
|
||||
zh: 2d35a5b0abad6e9f
|
||||
en: 5a71eeaa839ae17e
|
||||
zh: 02d8b24bf995e387
|
||||
@@ -7,6 +7,8 @@ A service can be a core spine service, a swappable capability seam, a bundle/com
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"]
|
||||
svc_cotTranslation["ctx.cotTranslation<br/>Reasoning translation Remote"]
|
||||
pkg_experimental_translator["experimental-translator"]
|
||||
svc_translator["ctx.translator<br/>Anonymous text translation"]
|
||||
pkg_hmr["hmr"]
|
||||
@@ -330,6 +332,7 @@ flowchart LR
|
||||
pkg_experimental_browser_use_playwright_mcp --> svc_browserUse
|
||||
pkg_experimental_browser_use_stagehand_native --> svc_browserUse
|
||||
pkg_experimental_claude_code_mods --> svc_claudeCodeMods
|
||||
pkg_experimental_client_ui_cot_translation --> svc_cotTranslation
|
||||
pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse
|
||||
pkg_experimental_computer_use_cua_driver_native --> svc_computerUse
|
||||
pkg_experimental_ptc_runtime_python --> svc_ptcRuntime
|
||||
@@ -564,6 +567,7 @@ flowchart LR
|
||||
svc_tools --> pkg_tool_terminal
|
||||
svc_tools --> pkg_tool_todo
|
||||
svc_tools --> pkg_tool_web
|
||||
svc_translator --> pkg_experimental_client_ui_cot_translation
|
||||
svc_typert --> pkg_api_gateway
|
||||
svc_typert --> pkg_typert_loader
|
||||
svc_userQuestions --> pkg_tool_ask_user
|
||||
@@ -588,7 +592,8 @@ flowchart LR
|
||||
|
||||
| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `ctx.translator` | `service` | [`experimental-translator`](../packages/experimental/translator) | - | - | - | Resolves Google or Bing requests and bounds provider calls independently of Sessions. |
|
||||
| `ctx.cotTranslation` | `service` | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | - | - | Serves reasoning translations, reuses saved results, and joins ordinary Session activation for uncached inactive Sessions. |
|
||||
| `ctx.translator` | `service` | [`experimental-translator`](../packages/experimental/translator) | - | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | Resolves Google or Bing requests and retains shared experimental records through the active Session writer. |
|
||||
| `ctx.hmr` | `core` | [`hmr`](../packages/boot/hmr) | - | [`app-boot`](../packages/boot/app-boot) | - | Owns module and exact configuration watchers; application mutations share its queue and automatic reloads await the application file lock. |
|
||||
| `ctx.pluginRegistryProbe` | `core` | [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager) | - | [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager) | - | Races public registry responses on the Host; the Client owns the initial registry recommendation. |
|
||||
| `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | Shares profile package operations with the CLI and reports persisted and running state to Web and agent callers. |
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"]
|
||||
svc_cotTranslation["ctx.cotTranslation<br/>Reasoning translation Remote"]
|
||||
pkg_experimental_translator["experimental-translator"]
|
||||
svc_translator["ctx.translator<br/>Anonymous text translation"]
|
||||
pkg_hmr["hmr"]
|
||||
@@ -332,6 +334,7 @@ flowchart LR
|
||||
pkg_experimental_browser_use_playwright_mcp --> svc_browserUse
|
||||
pkg_experimental_browser_use_stagehand_native --> svc_browserUse
|
||||
pkg_experimental_claude_code_mods --> svc_claudeCodeMods
|
||||
pkg_experimental_client_ui_cot_translation --> svc_cotTranslation
|
||||
pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse
|
||||
pkg_experimental_computer_use_cua_driver_native --> svc_computerUse
|
||||
pkg_experimental_ptc_runtime_python --> svc_ptcRuntime
|
||||
@@ -566,6 +569,7 @@ flowchart LR
|
||||
svc_tools --> pkg_tool_terminal
|
||||
svc_tools --> pkg_tool_todo
|
||||
svc_tools --> pkg_tool_web
|
||||
svc_translator --> pkg_experimental_client_ui_cot_translation
|
||||
svc_typert --> pkg_api_gateway
|
||||
svc_typert --> pkg_typert_loader
|
||||
svc_userQuestions --> pkg_tool_ask_user
|
||||
@@ -590,7 +594,8 @@ flowchart LR
|
||||
|
||||
| ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `ctx.translator` | `service` | [`experimental-translator`](../packages/experimental/translator) | - | - | - | 独立于 Session 解析 Google 或 Bing 请求,并限制 Provider 调用。 |
|
||||
| `ctx.cotTranslation` | `service` | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | - | - | 为展开的思考内容提供有界的 Session 翻译请求,不改变模型可见历史。 |
|
||||
| `ctx.translator` | `service` | [`experimental-translator`](../packages/experimental/translator) | - | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | 解析 Google 或 Bing 请求,并可选保留统一的实验性 Session 记录。 |
|
||||
| `ctx.hmr` | `core` | [`hmr`](../packages/boot/hmr) | - | [`app-boot`](../packages/boot/app-boot) | - | 负责模块和精确配置监听;应用修改共用其队列,自动重载等待应用文件锁。 |
|
||||
| `ctx.pluginRegistryProbe` | `core` | [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager) | - | [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager) | - | 在 Host 上并发比较公共安装源响应;初始安装源推荐由 Client 负责。 |
|
||||
| `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | 与 CLI 共享 profile 包操作,并向 Web 和 Agent 调用方分别报告持久状态与运行状态。 |
|
||||
|
||||
+30
-1
@@ -966,6 +966,34 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-claude-code-mods -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-cot-translation -->
|
||||
<a id="deepseek-aidsh-experimental-client-ui-cot-translation"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-client-ui-cot-translation`
|
||||
|
||||
- `inject`: `translator` · `typert` · `sessionController`
|
||||
- `refs`: [`TranslationProvider`](subsystems/translation.md) · `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/experimental/client-ui-cot-translation/src/index.ts:14`](../packages/experimental/client-ui-cot-translation/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Live preferences presented on the bundle's Plugins page. */
|
||||
export interface Config {
|
||||
/** Explicit translation provider, defaulting to Bing. */
|
||||
provider: Volatile<CotTranslationPreferences['provider']>
|
||||
/** Target language code; auto follows the browser UI locale. */
|
||||
targetLanguage: Volatile<string>
|
||||
}
|
||||
|
||||
/** Translation choices; auto targets the active browser UI language. */
|
||||
export interface CotTranslationPreferences {
|
||||
/** Explicit provider selection; requests never fall back to another provider. */
|
||||
provider: TranslationProvider
|
||||
/** BCP 47 language code, or auto to follow the UI language. */
|
||||
targetLanguage: string
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-cot-translation -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp -->
|
||||
<a id="deepseek-aidsh-experimental-computer-use-cua-driver-mcp"></a>
|
||||
|
||||
@@ -1230,7 +1258,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-translator`
|
||||
|
||||
- `source`: [`packages/experimental/translator/src/index.ts:19`](../packages/experimental/translator/src/index.ts)
|
||||
- `source`: [`packages/experimental/translator/src/index.ts:21`](../packages/experimental/translator/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Routing and bounds for one anonymous provider request. */
|
||||
@@ -4545,6 +4573,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
| `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-voice-input-bundle` | — | [`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-webworker-packer` | — | [`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts) |
|
||||
|
||||
@@ -968,6 +968,34 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-claude-code-mods -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-cot-translation -->
|
||||
<a id="deepseek-aidsh-experimental-client-ui-cot-translation"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-client-ui-cot-translation`
|
||||
|
||||
- `inject`: `translator` · `typert` · `sessionController`
|
||||
- `refs`: [`TranslationProvider`](subsystems/translation.zh.md) · `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/experimental/client-ui-cot-translation/src/index.ts:14`](../packages/experimental/client-ui-cot-translation/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Live preferences presented on the bundle's Plugins page. */
|
||||
export interface Config {
|
||||
/** Explicit translation provider, defaulting to Bing. */
|
||||
provider: Volatile<CotTranslationPreferences['provider']>
|
||||
/** Target language code; auto follows the browser UI locale. */
|
||||
targetLanguage: Volatile<string>
|
||||
}
|
||||
|
||||
/** Translation choices; auto targets the active browser UI language. */
|
||||
export interface CotTranslationPreferences {
|
||||
/** Explicit provider selection; requests never fall back to another provider. */
|
||||
provider: TranslationProvider
|
||||
/** BCP 47 language code, or auto to follow the UI language. */
|
||||
targetLanguage: string
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-cot-translation -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp -->
|
||||
<a id="deepseek-aidsh-experimental-computer-use-cua-driver-mcp"></a>
|
||||
|
||||
@@ -1232,7 +1260,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-translator`
|
||||
|
||||
- `source`: [`packages/experimental/translator/src/index.ts:19`](../packages/experimental/translator/src/index.ts)
|
||||
- `source`: [`packages/experimental/translator/src/index.ts:21`](../packages/experimental/translator/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Routing and bounds for one anonymous provider request. */
|
||||
@@ -4547,6 +4575,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-voice-input-bundle` | — | [`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-webworker-packer` | — | [`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts) |
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: a11f106978c9dce7
|
||||
zh: a11f106978c9dce7
|
||||
/experimental-plugin-record-catalog:
|
||||
en: 958fbffa8a71bc48
|
||||
zh: 01a673275685c0eb
|
||||
en: 84dbc9c06ab92877
|
||||
zh: b82ef2a46292ab0e
|
||||
@@ -11,4 +11,7 @@ Run `pnpm run gen-persistence-catalog` to regenerate both persistence catalogs,
|
||||
|
||||
Shared Session event declarations and resolved schemas appear in the [Session persistence event catalog](persistence-catalog.md).
|
||||
|
||||
No production package currently declares a plugin record.
|
||||
| Record | Owner | Purpose | Payload and source |
|
||||
|---|---|---|---|
|
||||
| `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | <code>TranslationRequestRecord</code> · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) |
|
||||
| `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | <code>TranslationResultRecord</code> · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) |
|
||||
@@ -11,4 +11,7 @@
|
||||
|
||||
共享 Session 事件声明与解析后的 schema 见[会话持久化事件目录](persistence-catalog.zh.md)。
|
||||
|
||||
当前没有生产包声明插件记录。
|
||||
| 记录 | 所属包 | 用途 | 载荷与源码 |
|
||||
|---|---|---|---|
|
||||
| `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | <code>TranslationRequestRecord</code> · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) |
|
||||
| `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | <code>TranslationResultRecord</code> · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) |
|
||||
+14
-3
@@ -256,9 +256,11 @@ flowchart TD
|
||||
pkg_experimental_claude_code_mods["experimental-claude-code-mods"]
|
||||
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
|
||||
pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"]
|
||||
pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"]
|
||||
pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"]
|
||||
pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
|
||||
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
|
||||
pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"]
|
||||
pkg_experimental_inspector["experimental-inspector"]
|
||||
pkg_experimental_inspector_profile["experimental-inspector-profile"]
|
||||
pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"]
|
||||
@@ -455,8 +457,6 @@ flowchart TD
|
||||
pkg_credentials_local --> pkg_credentials
|
||||
pkg_credentials_local --> pkg_home_paths
|
||||
pkg_credentials_local --> pkg_launch_environment
|
||||
pkg_experimental_translator --> pkg_timeout
|
||||
pkg_experimental_translator --> pkg_util_values
|
||||
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
|
||||
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
|
||||
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
|
||||
@@ -528,6 +528,10 @@ flowchart TD
|
||||
pkg_hmr --> pkg_app_boot
|
||||
pkg_hmr --> pkg_cmdline
|
||||
pkg_experimental_speech_to_text --> pkg_settings
|
||||
pkg_experimental_translator --> pkg_session
|
||||
pkg_experimental_translator --> pkg_session_persistence
|
||||
pkg_experimental_translator --> pkg_timeout
|
||||
pkg_experimental_translator --> pkg_util_values
|
||||
pkg_host_product_telemetry_otel --> pkg_otel
|
||||
pkg_ptc_runtime --> pkg_sandbox
|
||||
pkg_sandbox_local --> pkg_llm
|
||||
@@ -1340,6 +1344,11 @@ flowchart TD
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_experimental_claude_code_mods
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_session
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_typert_protocol
|
||||
pkg_experimental_client_ui_cot_translation --> pkg_api_session_controller
|
||||
pkg_experimental_client_ui_cot_translation --> pkg_experimental_translator
|
||||
pkg_experimental_client_ui_cot_translation --> pkg_session
|
||||
pkg_experimental_client_ui_cot_translation --> pkg_settings
|
||||
pkg_experimental_client_ui_cot_translation --> pkg_typert_protocol
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_gateway
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_remotes
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_session_controller
|
||||
@@ -1510,6 +1519,7 @@ flowchart TD
|
||||
| [`credentials`](../packages/credentials/credentials) | `credentials` | — |
|
||||
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
|
||||
| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — |
|
||||
| [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — |
|
||||
| [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — |
|
||||
| [`experimental-session-inspector`](../packages/experimental/session-inspector) | `experimental` | — |
|
||||
| [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — |
|
||||
@@ -1543,7 +1553,6 @@ flowchart TD
|
||||
| [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) |
|
||||
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
|
||||
| [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
|
||||
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
@@ -1583,6 +1592,7 @@ flowchart TD
|
||||
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
|
||||
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
|
||||
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
|
||||
| [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
|
||||
| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
|
||||
| [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
|
||||
@@ -1733,6 +1743,7 @@ flowchart TD
|
||||
| [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) |
|
||||
| [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`experimental-translator`](../packages/experimental/translator), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
|
||||
|
||||
+14
-3
@@ -258,9 +258,11 @@ flowchart TD
|
||||
pkg_experimental_claude_code_mods["experimental-claude-code-mods"]
|
||||
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
|
||||
pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"]
|
||||
pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"]
|
||||
pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"]
|
||||
pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
|
||||
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
|
||||
pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"]
|
||||
pkg_experimental_inspector["experimental-inspector"]
|
||||
pkg_experimental_inspector_profile["experimental-inspector-profile"]
|
||||
pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"]
|
||||
@@ -457,8 +459,6 @@ flowchart TD
|
||||
pkg_credentials_local --> pkg_credentials
|
||||
pkg_credentials_local --> pkg_home_paths
|
||||
pkg_credentials_local --> pkg_launch_environment
|
||||
pkg_experimental_translator --> pkg_timeout
|
||||
pkg_experimental_translator --> pkg_util_values
|
||||
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
|
||||
pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
|
||||
pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
|
||||
@@ -530,6 +530,10 @@ flowchart TD
|
||||
pkg_hmr --> pkg_app_boot
|
||||
pkg_hmr --> pkg_cmdline
|
||||
pkg_experimental_speech_to_text --> pkg_settings
|
||||
pkg_experimental_translator --> pkg_session
|
||||
pkg_experimental_translator --> pkg_session_persistence
|
||||
pkg_experimental_translator --> pkg_timeout
|
||||
pkg_experimental_translator --> pkg_util_values
|
||||
pkg_host_product_telemetry_otel --> pkg_otel
|
||||
pkg_ptc_runtime --> pkg_sandbox
|
||||
pkg_sandbox_local --> pkg_llm
|
||||
@@ -1342,6 +1346,11 @@ flowchart TD
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_experimental_claude_code_mods
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_session
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_typert_protocol
|
||||
pkg_experimental_client_ui_cot_translation --> pkg_api_session_controller
|
||||
pkg_experimental_client_ui_cot_translation --> pkg_experimental_translator
|
||||
pkg_experimental_client_ui_cot_translation --> pkg_session
|
||||
pkg_experimental_client_ui_cot_translation --> pkg_settings
|
||||
pkg_experimental_client_ui_cot_translation --> pkg_typert_protocol
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_gateway
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_remotes
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_session_controller
|
||||
@@ -1512,6 +1521,7 @@ flowchart TD
|
||||
| [`credentials`](../packages/credentials/credentials) | `credentials` | — |
|
||||
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
|
||||
| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — |
|
||||
| [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — |
|
||||
| [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — |
|
||||
| [`experimental-session-inspector`](../packages/experimental/session-inspector) | `experimental` | — |
|
||||
| [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — |
|
||||
@@ -1545,7 +1555,6 @@ flowchart TD
|
||||
| [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) |
|
||||
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
|
||||
| [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
|
||||
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
@@ -1585,6 +1594,7 @@ flowchart TD
|
||||
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
|
||||
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
|
||||
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
|
||||
| [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
|
||||
| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
|
||||
| [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
|
||||
@@ -1735,6 +1745,7 @@ flowchart TD
|
||||
| [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) |
|
||||
| [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`experimental-translator`](../packages/experimental/translator), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 2fcfc55fbac80f72
|
||||
zh: ea3016f394c56b07
|
||||
/web-client-slots/developer-provided-injection:
|
||||
en: 4fb5222bb2b064e7
|
||||
zh: b961f792052b4ce5
|
||||
en: 7081335b356bf46b
|
||||
zh: 631e60d29daf7196
|
||||
/web-client-slots/current-hierarchy:
|
||||
en: 8a4cc4b8608ce0f6
|
||||
zh: 21281dcf047503b2
|
||||
|
||||
@@ -106,6 +106,8 @@ A reserved `hooks` object in that return value accepts bare `getSnapshot`/`subsc
|
||||
|
||||
The owner of a slot may put an `inject` face in the child declaration when every occupant needs the same capability. Plain members reach all occupants unchanged. Function-valued members inside its `hooks` object are hook factories; they receive the slot's standard props and optional per-render `hookContext`, then return the constrained hook exposed to the occupant. `conversation.chat.node` uses this mechanism to provide `useTurnData(key)` for the node currently being rendered.
|
||||
|
||||
The session-scoped `conversation.chat.reasoning-body` chain receives unchanged `text` and `running` values for an expanded reasoning body. Its owner renders original compact Markdown when all entries decline; display contributions retain access to the original reasoning.
|
||||
|
||||
Use owner props for values already known at one render occurrence, registration `inject` for one entry's callbacks and private observables, slot-level `inject` for a capability controlled by the slot owner, and a declared store for mutable view state shared across entries or preserved across remounts. React nodes compose through child slots, not through injected values.
|
||||
|
||||
## Current hierarchy
|
||||
@@ -147,6 +149,7 @@ root
|
||||
│ │ └─ conversation.view
|
||||
│ │ ├─ conversation.chat.node
|
||||
│ │ │ ├─ conversation.chat.assistant-actions
|
||||
│ │ │ ├─ conversation.chat.reasoning-body
|
||||
│ │ │ ├─ conversation.chat.commandview
|
||||
│ │ │ ├─ conversation.chat.turnTail
|
||||
│ │ │ └─ tool.call.toolview
|
||||
|
||||
@@ -106,6 +106,8 @@ Renderer 还会根据声明的 store 创建 `useStore`,并根据声明的 loca
|
||||
|
||||
当每个 occupant 都需要同一种能力时,slot owner 可以在 child 声明里放置 `inject` face。普通成员会原样交给所有 occupant;其 `hooks` 对象中的函数成员是 hook factory,它会收到 slot 的标准 props 与可选的逐次渲染 `hookContext`,再返回提供给 occupant 的受限 hook。`conversation.chat.node` 正是通过这种机制,为当前渲染的 node 提供 `useTurnData(key)`。
|
||||
|
||||
Session 作用域的 `conversation.chat.reasoning-body` 链为展开的思考正文提供未修改的 `text` 和 `running`。所有条目都拒绝认领时,owner 渲染原始紧凑 Markdown;显示贡献保留查看原始思考内容的能力。
|
||||
|
||||
一次渲染时 owner 已知的值走 owner props;单个 entry 的 callback 与私有 observable 走注册项 `inject`;由 slot owner 控制、所有 occupant 共享的能力走 slot 级 `inject`;需要跨 entry 共享或跨重新挂载保留的可变视图状态走声明的 store。React node 通过 child slot 组合,不通过注入值传递。
|
||||
|
||||
## 当前层级
|
||||
@@ -147,6 +149,7 @@ root
|
||||
│ │ └─ conversation.view
|
||||
│ │ ├─ conversation.chat.node
|
||||
│ │ │ ├─ conversation.chat.assistant-actions
|
||||
│ │ │ ├─ conversation.chat.reasoning-body
|
||||
│ │ │ ├─ conversation.chat.commandview
|
||||
│ │ │ ├─ conversation.chat.turnTail
|
||||
│ │ │ └─ tool.call.toolview
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/translation.md
|
||||
/text-translation:
|
||||
en: b1e47fe6996df09d
|
||||
zh: 974f44f8dae7aba0
|
||||
en: 222da79896e1fcaa
|
||||
zh: 7f051c273631b050
|
||||
/text-translation/requests-and-results:
|
||||
en: 9d963e74f160b5ef
|
||||
zh: 99f5a7c187148743
|
||||
en: 82b3789c563bd265
|
||||
zh: 9c5513a23a607c9b
|
||||
/text-translation/failures-and-data-handling:
|
||||
en: 1c378d075d334bb0
|
||||
zh: b7bb5b2474df3a89
|
||||
en: bdc0f73d62290eb2
|
||||
zh: 0a3810c136d45012
|
||||
/text-translation/persistent-translations:
|
||||
en: e7eedc0b4b6dfde3
|
||||
zh: bfc593b3f219afdd
|
||||
/text-translation/reasoning-display:
|
||||
en: d7f824685bef2cd0
|
||||
zh: 6ec9406d66a69a5c
|
||||
@@ -2,20 +2,32 @@
|
||||
|
||||
English | [中文](translation.zh.md)
|
||||
|
||||
The experimental [translator](../../packages/experimental/translator/README.md) translates text through anonymous Google or Bing browser endpoints. It runs independently of Agents and Sessions; consumers own display, scheduling, and retention.
|
||||
The experimental [translator](../../packages/experimental/translator/README.md) translates text through anonymous Google or Bing browser endpoints. Consumers own display, scheduling, and Session activation; Session-bound requests retain reusable results through an active Session writer.
|
||||
|
||||
## Requests and results
|
||||
|
||||
`TranslationProvider` selects `google` or `bing`. `TranslationRequest` supplies `text`, `targetLanguage`, and optional `sourceLanguage` and `provider`. `resolve()` captures explicit routing in `TranslationSpec`: the configured provider defaults to Bing and omitted source language uses automatic detection. Common Chinese locale tags map to the selected provider's language codes. `translate()` returns plain text from the resolved provider.
|
||||
`TranslationProvider` selects `google` or `bing`. `TranslationRequest` supplies `text`, `targetLanguage`, and optional `sourceLanguage`, `provider` and branded `sessionId`. `resolve()` captures explicit routing in `TranslationSpec`: the configured provider defaults to Bing and omitted source language uses automatic detection. Common Chinese locale tags map to the selected provider's language codes. `translate()` returns plain text from the resolved provider.
|
||||
|
||||
The service exposes its configured `maxTextChars`, measured in UTF-16 code units. Consumers split longer inputs before requesting translation. Each call bounds the response bytes and includes the response-body read in its deadline. Caller cancellation and service disposal abort accepted requests; disposal waits for them to settle.
|
||||
|
||||
## Failures and data handling
|
||||
|
||||
`TranslationError` carries a `TranslationErrorCode` distinguishing text limits, HTTP failures, invalid responses, response limits, owned deadlines, and request failures. Provider diagnostics omit submitted text and response bodies. Failed calls do not select another provider. Responses are validated as external JSON and redirects are rejected.
|
||||
`TranslationError` carries a `TranslationErrorCode` distinguishing text limits, HTTP failures, invalid responses, response limits, owned deadlines, and request failures. `TRANSLATION_SESSION_REQUIRED` rejects a missing persistence backend or Session; `TRANSLATION_SESSION_INACTIVE` rejects an uncached inactive Session before provider dispatch; `TRANSLATION_STORAGE_ERROR` rejects invalid saved records or failed durability. Provider diagnostics omit submitted text and response bodies. Failed calls do not select another provider. Responses are validated as external JSON and redirects are rejected.
|
||||
|
||||
Google receives source text in a form POST body; Bing receives it in a JSON POST body. Both are remote services. Their anonymous endpoints provide no supported developer availability or quota guarantee. Regional connectivity must be verified on the user's network.
|
||||
|
||||
## Persistent translations
|
||||
|
||||
Session-bound calls use `plugin:translator/request` and `plugin:translator/result` for every provider. Requests retain exact source, language tags, selected provider and recipe identity; a successful result references its request sequence. Optional request metadata holds provider-specific details without changing these record types. The translator checkpoints requests before external dispatch and completed results before returning them. Read-only cache lookup precedes provider work and needs no live Session; failures and incomplete attempts do not supply reusable text. New records use the active Session's existing writer. The translator opens no write handle and does not activate a Session. Translation records preserve the original events and model input. Calls without a Session stay stateless. Experimental record retention across future format migrations is best-effort.
|
||||
|
||||
## Reasoning display
|
||||
|
||||
The [optional bundle](../../packages/experimental/cot-translation-bundle/README.md) composes the translator with the [GUI consumer](../../packages/experimental/client-ui-cot-translation/README.md). Its `cotTranslation` Remote serves authenticated browser calls. When a Session is inactive and has no saved result, the Host joins normal Session controller activation and retries once. Activation retains normal preset hooks, resume markers, and interrupted-turn repair; cancellation stops the caller's wait and prevents a later translation call without cancelling shared activation. Bundle settings select a translation service and target language; `auto` follows the active interface language.
|
||||
|
||||
`CotTranslationPreferences` contains the accepted provider and target language. `CotTranslationSnapshot` pairs these preferences with the translator's current `maxTextChars`. The browser reads this Host snapshot before registering translation controls, including when local settings storage is unavailable. Accepted settings updates and reconnects refresh both values; a changed request limit cancels and splits the expanded text again.
|
||||
|
||||
The session-scoped `conversation.chat.reasoning-body` chain receives the original `text` and `running` flag. Chat renders original Markdown when no contribution claims it. While enabled, the GUI translates completed paragraphs and request-sized prefixes of long streaming paragraphs only after expansion, and restores completed results from the current Session. Its local cache only optimizes that disclosure. The unfinished tail remains original. Original remains accessible. Closing the disclosure, switching provider or language, and unloading cancel outstanding calls and exclude late results. Provider failure preserves the original and requires an explicit retry; translated text never replaces original Session events or model input.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
@@ -24,6 +36,33 @@ Google receives source text in a form POST body; Bing receives it in a JSON POST
|
||||
|
||||
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
|
||||
|
||||
<a id="ctxcottranslation--cottranslationcontroller"></a>
|
||||
|
||||
### `ctx.cotTranslation` — `CotTranslationController`
|
||||
|
||||
Optional reasoning translation delegates Session-bound storage to the translator.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Read accepted translation preferences and the current request limit without sending text.
|
||||
* @param signal - browser query cancellation or Remote contribution withdrawal.
|
||||
* @returns authoritative preferences and maximum UTF-16 text length per request.
|
||||
*/
|
||||
@Remote limits(signal: AbortSignal): CotTranslationSnapshot
|
||||
|
||||
/**
|
||||
* Translate one displayed fragment through the reader's selected provider.
|
||||
* @param request - original text and Session identity supplied by the Client; provider and explicit
|
||||
* target language match accepted preferences, while auto uses the browser locale.
|
||||
* @param signal - browser cancellation or Remote contribution withdrawal.
|
||||
* @returns translated text; an uncached inactive Session joins ordinary GUI activation before retry.
|
||||
* Failures omit the source and provider response. Cancellation stops this caller's wait, not shared activation.
|
||||
*/
|
||||
@Remote async translate(request: TranslationRequest, signal: AbortSignal): Promise<string>
|
||||
```
|
||||
|
||||
Source: [`packages/experimental/client-ui-cot-translation/src/index.ts`](../../packages/experimental/client-ui-cot-translation/src/index.ts)
|
||||
|
||||
<a id="ctxtranslator--translator"></a>
|
||||
|
||||
### `ctx.translator` — `Translator`
|
||||
@@ -42,7 +81,9 @@ resolve(request: TranslationRequest): TranslationSpec
|
||||
* Translate one resolved specification; the selected provider receives its text.
|
||||
* @param spec - complete routing and language choices from `resolve()`.
|
||||
* @param signal - optional caller cancellation, combined with service disposal.
|
||||
* @returns translated plain text; rejects provider/limit failures and preserves cancellation reasons.
|
||||
* @returns translated plain text, durably retained before return when a Session is supplied.
|
||||
* An uncached supplied Session must be active; otherwise rejects with `TRANSLATION_SESSION_INACTIVE` before dispatch.
|
||||
* Rejects provider/storage/limit failures and preserves cancellation reasons.
|
||||
*/
|
||||
async translate(spec: TranslationSpec, signal?: AbortSignal): Promise<string>
|
||||
```
|
||||
|
||||
@@ -2,20 +2,32 @@
|
||||
|
||||
[English](translation.md) | 中文
|
||||
|
||||
实验性 [translator](../../packages/experimental/translator/README.zh.md) 通过免登录 Google 或 Bing 浏览器端点翻译文本。它独立于 Agent 和 Session 运行;消费者负责展示、调度与保留结果。
|
||||
实验性 [translator](../../packages/experimental/translator/README.zh.md) 通过免登录 Google 或 Bing 浏览器端点翻译文本。消费者负责展示、调度及 Session 激活;绑定 Session 的请求通过已激活 Session 的写入器保留可复用结果。
|
||||
|
||||
## 请求与结果
|
||||
|
||||
`TranslationProvider` 选择 `google` 或 `bing`。`TranslationRequest` 提供 `text`、`targetLanguage` 及可选的 `sourceLanguage` 和 `provider`。`resolve()` 将显式路由写入 `TranslationSpec`:配置的默认 Provider 为 Bing,省略源语言时自动检测。常见中文 locale 标签映射为所选 Provider 的语言代码。`translate()` 返回所选 Provider 翻译后的纯文本。
|
||||
`TranslationProvider` 选择 `google` 或 `bing`。`TranslationRequest` 提供 `text`、`targetLanguage` 及可选的 `sourceLanguage`、`provider` 与带品牌类型的 `sessionId`。`resolve()` 将显式路由写入 `TranslationSpec`:配置的默认 Provider 为 Bing,省略源语言时自动检测。常见中文 locale 标签映射为所选 Provider 的语言代码。`translate()` 返回所选 Provider 翻译后的纯文本。
|
||||
|
||||
服务公开配置的 `maxTextChars`,单位为 UTF-16 code unit。消费者先拆分超长输入,再请求翻译。每次调用限制响应字节数,并将响应体读取计入超时。调用者取消和服务卸载都会中止已接收的请求;卸载等待这些请求完成。
|
||||
|
||||
## 失败与数据处理
|
||||
|
||||
`TranslationError` 的 `TranslationErrorCode` 区分文本超限、HTTP 失败、无效响应、响应超限、服务自身超时与请求失败。Provider 错误信息省略提交的文本与响应体。失败时不选择其他 Provider。响应作为外部 JSON 校验,重定向被拒绝。
|
||||
`TranslationError` 的 `TranslationErrorCode` 区分文本超限、HTTP 失败、无效响应、响应超限、服务自身超时与请求失败。`TRANSLATION_SESSION_REQUIRED` 拒绝缺失的持久化后端或 Session,`TRANSLATION_SESSION_INACTIVE` 在调用 Provider 前拒绝未激活且缓存未命中的 Session,`TRANSLATION_STORAGE_ERROR` 拒绝无效保存记录或持久化失败。Provider 错误信息省略提交的文本与响应体。失败时不选择其他 Provider。响应作为外部 JSON 校验,重定向被拒绝。
|
||||
|
||||
Google 在 form POST 请求体中接收原文,Bing 在 JSON POST 请求体中接收原文。两者都是远端服务。免登录端点不保证开发者 API 的可用性或免费额度。区域连通性须在用户网络上验证。
|
||||
|
||||
## 持久化翻译
|
||||
|
||||
绑定 Session 的调用对所有 Provider 使用 `plugin:translator/request` 与 `plugin:translator/result`。请求保留精确原文、语言标签、所选 Provider 和翻译规则标识,成功结果引用请求序号。可选请求 metadata 保存 Provider 专有细节,不改变记录类型。translator 在外部派发前完成请求检查点,在返回前完成结果检查点。只读缓存查找先于 Provider 调用,且不要求 Session 已激活;失败或未完成尝试不提供可复用译文。新记录使用已激活 Session 的现有写入器。translator 不打开写句柄,也不激活 Session。翻译记录保留原始事件与模型输入。不指定 Session 的调用保持无状态。未来格式迁移对实验性记录的保留为尽力而为。
|
||||
|
||||
## 思考内容展示
|
||||
|
||||
[可选 Bundle](../../packages/experimental/cot-translation-bundle/README.zh.md) 将 translator 与 [GUI 消费者](../../packages/experimental/client-ui-cot-translation/README.zh.md) 组合。其 `cotTranslation` Remote 为经过身份验证的浏览器调用提供服务。Session 未激活且没有已保存结果时,Host 等待 Session controller 的正常激活,再重试一次。激活保留正常的 preset 钩子、恢复标记和中断轮次修复;取消会结束调用者的等待并阻止随后调用翻译,不取消共享激活。Bundle 设置选择翻译服务及目标语言;`auto` 跟随当前界面语言。
|
||||
|
||||
`CotTranslationPreferences` 包含已接受的翻译服务和目标语言。`CotTranslationSnapshot` 将这些偏好与 translator 当前的 `maxTextChars` 一起返回。浏览器在注册翻译控件前读取此 Host 快照,本地设置存储不可用时也如此。接受的设置更新及重新连接会刷新这两项值;请求长度限制变化时,展开内容的请求被取消并重新分片。
|
||||
|
||||
Session 作用域的 `conversation.chat.reasoning-body` chain 接收原始 `text` 和 `running` 标志。没有 contribution 接管时,Chat 渲染原始 Markdown。启用后,GUI 只在展开后翻译完整段落和流式长段落中达到请求长度的前缀,并从当前 Session 恢复已完成结果;本地缓存只优化当前展开区域。未完成的末尾片段保留原文。原文始终可访问。关闭展开内容、切换 Provider 或语言、卸载插件都会取消未完成调用并忽略迟到的结果。Provider 失败时保留原文,必须显式重试;译文从不替换原始 Session 事件或模型输入。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
@@ -24,6 +36,33 @@ Google 在 form POST 请求体中接收原文,Bing 在 JSON POST 请求体中
|
||||
|
||||
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
|
||||
|
||||
<a id="ctxcottranslation--cottranslationcontroller"></a>
|
||||
|
||||
### `ctx.cotTranslation` — `CotTranslationController`
|
||||
|
||||
Optional reasoning translation delegates Session-bound storage to the translator.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Read accepted translation preferences and the current request limit without sending text.
|
||||
* @param signal - browser query cancellation or Remote contribution withdrawal.
|
||||
* @returns authoritative preferences and maximum UTF-16 text length per request.
|
||||
*/
|
||||
@Remote limits(signal: AbortSignal): CotTranslationSnapshot
|
||||
|
||||
/**
|
||||
* Translate one displayed fragment through the reader's selected provider.
|
||||
* @param request - original text and Session identity supplied by the Client; provider and explicit
|
||||
* target language match accepted preferences, while auto uses the browser locale.
|
||||
* @param signal - browser cancellation or Remote contribution withdrawal.
|
||||
* @returns translated text; an uncached inactive Session joins ordinary GUI activation before retry.
|
||||
* Failures omit the source and provider response. Cancellation stops this caller's wait, not shared activation.
|
||||
*/
|
||||
@Remote async translate(request: TranslationRequest, signal: AbortSignal): Promise<string>
|
||||
```
|
||||
|
||||
Source: [`packages/experimental/client-ui-cot-translation/src/index.ts`](../../packages/experimental/client-ui-cot-translation/src/index.ts)
|
||||
|
||||
<a id="ctxtranslator--translator"></a>
|
||||
|
||||
### `ctx.translator` — `Translator`
|
||||
@@ -42,7 +81,9 @@ resolve(request: TranslationRequest): TranslationSpec
|
||||
* Translate one resolved specification; the selected provider receives its text.
|
||||
* @param spec - complete routing and language choices from `resolve()`.
|
||||
* @param signal - optional caller cancellation, combined with service disposal.
|
||||
* @returns translated plain text; rejects provider/limit failures and preserves cancellation reasons.
|
||||
* @returns translated plain text, durably retained before return when a Session is supplied.
|
||||
* An uncached supplied Session must be active; otherwise rejects with `TRANSLATION_SESSION_INACTIVE` before dispatch.
|
||||
* Rejects provider/storage/limit failures and preserves cancellation reasons.
|
||||
*/
|
||||
async translate(spec: TranslationSpec, signal?: AbortSignal): Promise<string>
|
||||
```
|
||||
|
||||
@@ -223,6 +223,7 @@ export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-bas
|
||||
export const OPTIONAL_BUNDLES: readonly string[] = [
|
||||
'@deepseek-ai/dsh-experimental-agent-team-profile',
|
||||
'@deepseek-ai/dsh-experimental-voice-input-bundle',
|
||||
'@deepseek-ai/dsh-experimental-cot-translation-bundle',
|
||||
'@deepseek-ai/dsh-experimental-auto-review',
|
||||
'@deepseek-ai/dsh-experimental-inspector-profile',
|
||||
'@deepseek-ai/dsh-experimental-tool-worktree',
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 0ca3e8c4eab1ca78
|
||||
zh: 1a1c71bab151eb7d
|
||||
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
|
||||
en: a4f1557cdbae804e
|
||||
zh: 159dac17d36d8b4d
|
||||
en: c769b9eaddf02549
|
||||
zh: e36efc654be557e2
|
||||
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
|
||||
en: 69b021c5a5d8b5a7
|
||||
zh: 091ae7986c7a8a20
|
||||
|
||||
@@ -105,6 +105,8 @@ Scroll-edge fades initialize when `ResizeObserver` reports the open group's layo
|
||||
|
||||
Each group owns local `useDisclosure` state that survives mode changes while its component stays mounted.
|
||||
|
||||
The `conversation.chat.reasoning-body` chain offers the original reasoning text and streaming state only when its body is expanded. An unclaimed body keeps compact Markdown; a claiming display extension must preserve access to the original text. Extensions do not replace the reasoning preview or its disclosure policy.
|
||||
|
||||
The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. Source callbacks retain their receiver and stable identity. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state.
|
||||
|
||||
Process rows share one text and icon color in every Work details mode: tertiary at rest and secondary on hover, while error and warning text keeps its semantic color. Leading icons, disclosure chevrons, and their boxes scale with the content font-size delta.
|
||||
|
||||
@@ -105,6 +105,8 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过
|
||||
|
||||
每个组拥有本地 `useDisclosure` 状态,组件保持挂载时,模式切换保留该状态。
|
||||
|
||||
`conversation.chat.reasoning-body` 链仅在思考正文展开时提供原始思考文本和流式状态。未被认领的正文使用紧凑 Markdown;认领显示的扩展必须保留查看原文的能力。扩展不替换思考预览或展开策略。
|
||||
|
||||
Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。来源回调保留接收对象及稳定引用。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。
|
||||
|
||||
过程行的文字与图标在所有工作过程展示模式下共用一种颜色:默认 tertiary、悬停 secondary,错误与警告文字保留语义色。前置图标、折叠箭头及其容器随正文字号增量缩放。
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, memo, useMemo } from 'react'
|
||||
import { fileMediaUrl } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ReactNode } from 'react'
|
||||
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -38,6 +39,8 @@ export interface AssistantMarkdownProps {
|
||||
interrupted?: boolean | undefined
|
||||
/** Render consecutive image blocks through the attachment slot. */
|
||||
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
|
||||
/** Optional expanded reasoning extension; absent for standalone renderers. */
|
||||
renderReasoningBody?: PropsRenderSlots<'conversation.chat.reasoning-body'>['renderSlotChain'] | undefined
|
||||
/** Hide reasoning that belongs to the Turn-level process disclosure. */
|
||||
reasoningHidden?: boolean | undefined
|
||||
/** Live display policy for reasoning summaries. */
|
||||
@@ -53,7 +56,7 @@ export interface AssistantMarkdownProps {
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, renderMessageImages, groupPart, useDisclosure,
|
||||
reasoningHidden = false, usePresentation, revealProcess, mentions, t,
|
||||
reasoningHidden = false, usePresentation, revealProcess, mentions, renderReasoningBody, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
@@ -97,7 +100,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
reveal={revealProcess}
|
||||
>
|
||||
<ReasoningRow text={block.text} running={streaming && i === last} usePresentation={usePresentation}
|
||||
useDisclosure={useDisclosure} t={t} />
|
||||
useDisclosure={useDisclosure} renderReasoningBody={renderReasoningBody} t={t} />
|
||||
</ProcessReasoning>,
|
||||
)
|
||||
break
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ChatNodeViewProps, PresentationInjected, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
|
||||
type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace<PresentationInjected>
|
||||
& PropsRenderSlots<'conversation.chat.reasoning-body'>
|
||||
|
||||
/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
|
||||
export const AssistantNodeView = memo(function AssistantNodeView({
|
||||
node, groupPart, useDisclosure, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, usePresentation, t,
|
||||
node, groupPart, useDisclosure, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions,
|
||||
usePresentation, renderSlotChain, t,
|
||||
}: AssistantNodeViewProps) {
|
||||
const data = node.data
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
@@ -39,6 +41,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({
|
||||
renderMessageImages={renderMessageImages}
|
||||
reasoningHidden={reasoningHidden}
|
||||
usePresentation={usePresentation}
|
||||
renderReasoningBody={renderSlotChain}
|
||||
revealProcess={revealProcess}
|
||||
mentions={mentions}
|
||||
t={t}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Assistant reasoning disclosure, independent of Tool-call presentation. */
|
||||
import { memo, useMemo } from 'react'
|
||||
import { DisclosureRow, IconThinkOutlineRegular, MarkdownText, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ChatViewSlotProps, UseDisclosure, UsePresentation } from '../contract/slots.ts'
|
||||
import { markdownLabels } from '../markdown-labels.ts'
|
||||
import a11yCss from './accessibility.module.css'
|
||||
@@ -41,14 +42,16 @@ function latestCompletedParagraphFirstLine(text: string): string {
|
||||
* @param props.running - whether this block is the streaming tail.
|
||||
* @param props.usePresentation - live display-policy selector for this reasoning row.
|
||||
* @param props.useDisclosure - independent open state with enclosing-Turn resets.
|
||||
* @param props.renderReasoningBody - optional expanded-body extension with original Markdown fallback.
|
||||
* @param props.t - conversation locale seat for status and Markdown actions.
|
||||
* @returns the reasoning disclosure.
|
||||
*/
|
||||
export const ReasoningRow = memo(function ReasoningRow({ text, running, usePresentation, useDisclosure, t }: {
|
||||
export const ReasoningRow = memo(function ReasoningRow({ text, running, usePresentation, useDisclosure, renderReasoningBody, t }: {
|
||||
text: string
|
||||
running: boolean
|
||||
useDisclosure: UseDisclosure
|
||||
usePresentation: UsePresentation
|
||||
renderReasoningBody?: PropsRenderSlots<'conversation.chat.reasoning-body'>['renderSlotChain'] | undefined
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const { expanded, toggle } = useDisclosure()
|
||||
@@ -69,9 +72,13 @@ export const ReasoningRow = memo(function ReasoningRow({ text, running, usePrese
|
||||
), [running, summary])
|
||||
const content = useMemo(() => expanded ? (
|
||||
<div className={css.thinkBody}>
|
||||
<MarkdownText text={text} streaming={running} labels={labels} variant="compact" />
|
||||
{renderReasoningBody === undefined
|
||||
? <MarkdownText text={text} streaming={running} labels={labels} variant="compact" />
|
||||
: renderReasoningBody('conversation.chat.reasoning-body', { text, running }, {
|
||||
fallback: <MarkdownText text={text} streaming={running} labels={labels} variant="compact" />,
|
||||
})}
|
||||
</div>
|
||||
) : undefined, [expanded, labels, running, text])
|
||||
) : undefined, [expanded, labels, renderReasoningBody, running, text])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -41,6 +41,7 @@ export function registerChatNodeRenderers(
|
||||
name: 'conversation.chat.node',
|
||||
key: 'assistant-step',
|
||||
locale: NS,
|
||||
children: { 'conversation.chat.reasoning-body': { kind: 'chain', scope: 'session' } },
|
||||
inject: () => ({ hooks: { presentation } }),
|
||||
}, AssistantNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
|
||||
@@ -68,6 +68,14 @@ export interface QuotaNoticeState {
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** Original reasoning offered to optional display-only renderers. */
|
||||
export interface ReasoningBodyOwnerProps {
|
||||
/** Complete or streaming model-provided reasoning, unchanged by display extensions. */
|
||||
text: string
|
||||
/** Whether the reasoning tail is still streaming. */
|
||||
running: boolean
|
||||
}
|
||||
|
||||
/** Owner currency of one quota notice offered to the frame-wide chain. */
|
||||
export interface QuotaNoticeOwnerProps {
|
||||
/** Stable failure code retained in the Session log. */
|
||||
@@ -307,6 +315,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* replaces that command renderer; an unoccupied key uses the generic card.
|
||||
*/
|
||||
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
|
||||
/**
|
||||
* Display-only rendering of an expanded reasoning body. The first matching
|
||||
* contribution renders; an unclaimed body uses the original compact Markdown.
|
||||
* Contributions must preserve access to the original model-provided text.
|
||||
*/
|
||||
'conversation.chat.reasoning-body': { kind: 'chain'; scope: 'session'; owner: ReasoningBodyOwnerProps }
|
||||
/**
|
||||
* Ordered feature contributions before a completed Turn's action row. Each
|
||||
* entry receives the Turn, closing sequence, and file opener. A fresh `id`
|
||||
|
||||
@@ -35,7 +35,7 @@ export type {
|
||||
AssistantActionOwnerProps, ChatFileMentions, ChatNodeHookContext, ChatNodeInjected, ChatNodeOwnerProps,
|
||||
ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps,
|
||||
CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions, PresentationInjected,
|
||||
QuotaNoticeCode, QuotaNoticeHostProps, QuotaNoticeInjected, QuotaNoticeOwnerProps, QuotaNoticeState,
|
||||
QuotaNoticeCode, QuotaNoticeHostProps, QuotaNoticeInjected, QuotaNoticeOwnerProps, QuotaNoticeState, ReasoningBodyOwnerProps,
|
||||
TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, UseDisclosure, UsePresentation,
|
||||
} from './contract/slots.ts'
|
||||
export type {
|
||||
|
||||
@@ -342,7 +342,8 @@ function makeHarness(
|
||||
case 'context':
|
||||
return <ContextMessageNodeView {...nodeProps} node={nodeOwner.node} />
|
||||
case 'assistant-step':
|
||||
return <AssistantNodeView {...nodeProps} node={nodeOwner.node} usePresentation={props.usePresentation} />
|
||||
return <AssistantNodeView {...nodeProps} node={nodeOwner.node} usePresentation={props.usePresentation}
|
||||
renderSlotChain={(_slot, _owner, options) => options?.fallback} />
|
||||
case 'command':
|
||||
return (
|
||||
<CommandNodeView
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { ChainRenderOpts } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
@@ -20,6 +21,18 @@ const t = makeTranslate(zh, commonZh)
|
||||
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
|
||||
|
||||
describe('ReasoningRow', () => {
|
||||
it('offers only expanded reasoning to its display extension and preserves original Markdown fallback', () => {
|
||||
const extension = vi.fn((_slot: string, _owner: object, options?: ChainRenderOpts) => options?.fallback)
|
||||
const view = render(<ReasoningRow useDisclosure={useDisclosure} text="Original thought" running={false}
|
||||
usePresentation={useDetailedPresentation} renderReasoningBody={extension} t={t} />)
|
||||
expect(extension).not.toHaveBeenCalled()
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(extension.mock.calls[0]?.[0]).toBe('conversation.chat.reasoning-body')
|
||||
expect(extension.mock.calls[0]?.[1]).toEqual({ text: 'Original thought', running: false })
|
||||
expect(extension.mock.calls[0]?.[2]?.fallback).toBeDefined()
|
||||
expect(view.getAllByText('Original thought')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['', ''],
|
||||
['An unfinished line', ''],
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 1eb35b015a6c423f
|
||||
zh: a8a3dceee2c9e943
|
||||
/packages-experimental/packages:
|
||||
en: 47534e5544c5c128
|
||||
zh: 292c8de8c087bcdc
|
||||
en: dc594ae77b05499c
|
||||
zh: 06090a11e2144b47
|
||||
/packages-experimental/related-documentation:
|
||||
en: 1cb9d3448d44e48d
|
||||
zh: cd326d70a746b896
|
||||
|
||||
@@ -25,6 +25,8 @@ Experimental prototypes may change their contracts and carry no support promise.
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`translator`](translator/README.md) | Anonymous Google and Bing text translation | `ctx.translator` |
|
||||
| [`client-ui-cot-translation`](client-ui-cot-translation/README.md) | Expanded reasoning translation and original-text controls | `ctx.cotTranslation` |
|
||||
| [`cot-translation-bundle`](cot-translation-bundle/README.md) | Default-disabled reasoning translation composition | — |
|
||||
| [`speech-to-text`](speech-to-text/README.md) | Named speech recognition providers | `ctx.speechToText` |
|
||||
| [`speech-to-text-sensevoice`](speech-to-text-sensevoice/README.md) | Managed local SenseVoice inference | — |
|
||||
| [`api-speech-to-text`](api-speech-to-text/README.md) | Authenticated transient transcription Remote | `ctx.speechController` |
|
||||
|
||||
@@ -25,6 +25,8 @@ kind: "package-group"
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| [`translator`](translator/README.zh.md) | 免登录 Google 与 Bing 文本翻译 | `ctx.translator` |
|
||||
| [`client-ui-cot-translation`](client-ui-cot-translation/README.zh.md) | 展开的思考内容翻译与原文控件 | `ctx.cotTranslation` |
|
||||
| [`cot-translation-bundle`](cot-translation-bundle/README.zh.md) | 默认禁用的思考内容翻译组合 | — |
|
||||
| [`speech-to-text`](speech-to-text/README.zh.md) | 具名语音识别 Provider | `ctx.speechToText` |
|
||||
| [`speech-to-text-sensevoice`](speech-to-text-sensevoice/README.zh.md) | 托管本地 SenseVoice 推理 | — |
|
||||
| [`api-speech-to-text`](api-speech-to-text/README.zh.md) | 带认证的临时转写 Remote | `ctx.speechController` |
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/client-ui-cot-translation/README.md
|
||||
/:
|
||||
en: c2a264438157aa27
|
||||
zh: b52d9d387759a1b6
|
||||
/deepseek-ai-dsh-experimental-client-ui-cot-translation:
|
||||
en: f0c620cc1849b0a4
|
||||
zh: dce4b301af1d6ef6
|
||||
/deepseek-ai-dsh-experimental-client-ui-cot-translation/summary:
|
||||
en: 8d78ff49b12e8101
|
||||
zh: f04c7111dceef20f
|
||||
/deepseek-ai-dsh-experimental-client-ui-cot-translation/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: ce40cab8e7534137
|
||||
/deepseek-ai-dsh-experimental-client-ui-cot-translation/use-this-package:
|
||||
en: b0263b47f7e4b6fd
|
||||
zh: a49f29c7aba102ee
|
||||
/deepseek-ai-dsh-experimental-client-ui-cot-translation/understand-the-implementation:
|
||||
en: ac76b28aa05c23d3
|
||||
zh: 01b60d674054d19b
|
||||
/deepseek-ai-dsh-experimental-client-ui-cot-translation/further-exploration:
|
||||
en: f4a5c92999a62f37
|
||||
zh: e1e69493f11f1368
|
||||
/deepseek-ai-dsh-experimental-client-ui-cot-translation/model-experience:
|
||||
en: 8c01da99bdb2b637
|
||||
zh: 9c5f4a7e58686ff5
|
||||
/deepseek-ai-dsh-experimental-client-ui-cot-translation/model-experience/kv-cache-effect:
|
||||
en: f8bec91e02480659
|
||||
zh: 107ec5e849e530dd
|
||||
/deepseek-ai-dsh-experimental-client-ui-cot-translation/known-limitations-and-deferred-work:
|
||||
en: 280e59a910068659
|
||||
zh: 8230bebfd7b18cf6
|
||||
/deepseek-ai-dsh-experimental-client-ui-cot-translation/known-limitations-and-deferred-work/dev-note:
|
||||
en: 0225ba087c5266f3
|
||||
zh: 0400e21a2683a23c
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
description: "Machine-translate expanded reasoning while retaining the original text and saved results."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-client-ui-cot-translation
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Read expanded model reasoning in another language and switch back to its original text. Choose a translation service and target language from the Plugins panel. Translation sends displayed reasoning to the selected external service, which may include private code or conversation details. Original reasoning remains in the Session and model context.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Further Exploration](#further-exploration)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Enable the [reasoning translation bundle](../cot-translation-bundle/README.md) in Plugins, then open a reasoning disclosure. The feature is absent until the bundle is enabled. Its bundle details provide staged Translation service and Target language controls; Save applies both preferences. Bing is the default provider, and `auto` targets the active UI language. An explicit language code such as `zh`, `en`, or `ja` overrides that choice.
|
||||
|
||||
Translation and Original switch the expanded body without changing the reasoning preview or making another request. Completed paragraphs and request-sized prefixes of long streaming paragraphs translate asynchronously; the unfinished tail stays original. A failure retains the original body and provides Retry; the Translation button remains disabled until the retry starts. Closing the disclosure or disabling the bundle cancels its requests.
|
||||
|
||||
For custom compositions, mount this plugin alongside the [translator service](../translator/README.md) and the ordinary Web GUI. The plugin's configuration fields are `provider` and `targetLanguage`; the generated [configuration catalog](../../../docs/config-catalog.md) owns their complete schema.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Maintainer details — click to expand</summary>
|
||||
|
||||
The browser contributes to Chat's expanded reasoning-body chain while the Host exposes cancellation-aware translation Remote methods. The Host supplies accepted provider and language preferences before the browser enables translation. Settings updates and reconnects refresh these preferences and the translator's request limit. A changed limit cancels and splits the expanded text again. One disclosure lifecycle controller serializes completed fragments, caches their translations while mounted, preserves whitespace and Unicode pairs when splitting long paragraphs, and excludes results after cancellation.
|
||||
|
||||
The browser binds each request to the disclosure's Session. The Host requires the accepted provider and any explicitly configured target language; `auto` uses the browser locale. The authenticated Client supplies source text and Session identity, and the Host does not verify that the source text belongs to that Session. The translator durably stores provider-independent request/result records and reuses completed results after collapse, reload or reopening the Session. The browser cache only optimizes the current display.
|
||||
|
||||
A saved result needs no activation. On an uncached inactive Session, the Host joins the ordinary Session controller activation and retries translation once through its existing writer. This uses normal preset startup hooks, resume markers, and interrupted-turn repair. Cancellation ends this caller's wait and prevents later translation dispatch; shared activation can still finish for other readers.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
- [Translator service](../translator/README.md) — providers and network limits.
|
||||
- [Chat rendering](../../client/ui-chat/README.md) — reasoning disclosure and display extensions.
|
||||
- [Web Client Slots](../../../docs/subsystems/slots.md) — contribution lifetimes.
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through normal Session activation on uncached historical reasoning; the Session's preset owns startup hooks and any context they add.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Translation records do not replace model input. Normal activation may append context or repair an interrupted turn, with the same cache effects as opening the conversation.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Google and Bing anonymous endpoints may reject requests or become unavailable; requests never fall back to another provider.
|
||||
- Translation may change Markdown formatting or code examples. Original text remains available.
|
||||
- An inactive subagent Session without a saved translation retains the original text and reports a translation failure; ordinary Session activation cannot restore subagent ownership.
|
||||
- Collapsed previews remain original. Saved results belong to the Session; future format migrations retain experimental records on a best-effort basis.
|
||||
- Provider reachability from mainland China requires network-specific verification.
|
||||
|
||||
-----
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Maintainer details — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
description: "对展开的思考内容进行机器翻译,同时保留原文和已保存结果。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-client-ui-cot-translation
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
以其他语言阅读展开的模型思考内容,并随时切回原文。在插件面板中选择翻译服务和目标语言。翻译会将显示的思考内容发送给所选外部服务,其中可能包含私有代码或对话细节。Session 和模型上下文保留原始思考内容。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用本包](#use-this-package)
|
||||
- [了解实现](#understand-the-implementation)
|
||||
- [延伸阅读](#further-exploration)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与后续工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
在插件中启用[思考过程翻译组合包](../cot-translation-bundle/README.zh.md),然后展开思考内容。启用组合包之前,此功能不会出现。组合包详情页提供暂存的翻译服务和目标语言设置;保存后两项选择一起生效。默认翻译服务为 Bing,`auto` 跟随当前界面语言。填写 `zh`、`en` 或 `ja` 等语言代码可指定目标语言。
|
||||
|
||||
译文和原文按钮切换展开的正文,不改变思考预览,也不重复发送请求。完整段落和流式长段落中达到请求长度的前缀异步翻译,未完成的末尾片段保留原文。失败时保留原始正文,并提供重试;开始重试前,译文按钮保持禁用。关闭思考内容或停用组合包会取消对应请求。
|
||||
|
||||
自定义组合可将本插件与[翻译服务](../translator/README.zh.md)和普通 Web GUI 一起挂载。本插件的配置字段是 `provider` 和 `targetLanguage`;完整定义见生成的[配置目录](../../../docs/config-catalog.zh.md)。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 了解实现
|
||||
|
||||
<details>
|
||||
<summary>维护者细节 — 点击展开</summary>
|
||||
|
||||
浏览器向 Chat 展开的思考正文链提供渲染贡献,Host 暴露可取消的翻译 Remote 方法。Host 在浏览器启用翻译前提供已接受的翻译服务和语言偏好。设置更新及重新连接会刷新这些偏好和 translator 的请求长度限制。限制变化时,展开内容的请求被取消并重新分片。每个展开区域的生命周期控制器串行处理完整片段,在挂载期间缓存译文,在拆分长段落时保留空白和 Unicode 代理对,并忽略取消后的结果。
|
||||
|
||||
浏览器将每个请求绑定到展开区域的 Session。Host 要求请求使用已接受的提供方以及配置中显式指定的目标语言;`auto` 使用浏览器区域语言。已认证的 Client 提供原文与 Session 身份,Host 不校验原文是否属于该 Session。translator 持久化与 Provider 无关的请求/结果记录,在折叠、重载或重新打开 Session 后复用已完成结果。浏览器缓存只优化当前展示。
|
||||
|
||||
已保存结果无需激活 Session。未激活 Session 缓存未命中时,Host 等待普通 Session controller 的激活流程,再通过现有写入器重试一次翻译。此过程使用正常的 preset 启动钩子、恢复标记和中断轮次修复。取消会结束当前调用者的等待,并阻止随后派发翻译;共享激活仍可为其他读者继续完成。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 延伸阅读
|
||||
|
||||
- [翻译服务](../translator/README.zh.md) — 服务接口和网络限制。
|
||||
- [Chat 渲染](../../client/ui-chat/README.zh.md) — 思考内容展开和显示扩展。
|
||||
- [Web Client Slots](../../../docs/subsystems/slots.zh.md) — 渲染贡献的生命周期。
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
间接影响来自对未缓存历史思考内容的正常 Session 激活;Session 的 preset 负责启动钩子及其添加的上下文。
|
||||
|
||||
#### KV Cache 效果
|
||||
|
||||
翻译记录不替换模型输入。正常激活可能追加上下文或修复中断轮次,其缓存影响与打开对话相同。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Google 和 Bing 匿名接口可能拒绝请求或不可用;请求不会回退到其他服务。
|
||||
- 翻译可能改变 Markdown 格式或代码示例,原文始终可查看。
|
||||
- 未激活的子代理 Session 若没有已保存译文,会保留原文并显示翻译失败;普通 Session 激活无法恢复子代理所有权。
|
||||
- 折叠的预览保留原文。已保存结果属于 Session;未来格式迁移以尽力而为的方式保留实验性记录。
|
||||
- 中国大陆的服务可达性需要在对应网络中验证。
|
||||
|
||||
-----
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者细节 — 点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Machine translation of reasoning",
|
||||
"description": "Display expanded reasoning in another language using machine translation."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "思考过程机器翻译",
|
||||
"description": "将展开的思考内容机器翻译为其他语言。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-experimental-client-ui-cot-translation",
|
||||
"description": "Optional machine translation of expanded reasoning, preserving original Session text",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/experimental/client-ui-cot-translation"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
},
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json",
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts"
|
||||
}
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-chat",
|
||||
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-translator": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/dsh-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-translator": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-chat": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* Translation shares reasoning typography and the Plugins form's tokens. */
|
||||
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-height: 28px; margin-bottom: 4px; }
|
||||
.failure { color: var(--dsw-alias-label-primary); font-size: var(--dsh-content-font-size-secondary, 13px); }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field label { font-size: 13px; color: var(--dsw-alias-label-primary); }
|
||||
.field select { min-height: 32px; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); padding: 0 8px; font: inherit; }
|
||||
.notice { margin: 0; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); }
|
||||
|
||||
.spinner { animation: cot-translation-spin 1s linear infinite; }
|
||||
@keyframes cot-translation-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Translation controls for one expanded reasoning body. */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, MarkdownText, IconLoadingOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CotTranslationPreferences } from '../preferences.ts'
|
||||
import { ReasoningTranslation, type TranslateText, type TranslationState } from './translation.ts'
|
||||
import { NS } from './locales.ts'
|
||||
import css from './Translation.module.css'
|
||||
|
||||
/** Registry-side values and callbacks bound for the reasoning contribution. */
|
||||
export interface TranslationBodyInjected {
|
||||
hooks: {
|
||||
preferences: HostObservable<CotTranslationPreferences>
|
||||
translationLocale: HostObservable<LocaleSnapshot>
|
||||
translationLimit: HostObservable<number>
|
||||
}
|
||||
translate: TranslateText
|
||||
}
|
||||
|
||||
export type TranslationBodyProps = PropsRuntime<'conversation.chat.reasoning-body'>
|
||||
& PropsLocale<typeof NS> & InjectFace<TranslationBodyInjected>
|
||||
|
||||
/**
|
||||
* Render translated reasoning, retaining the complete original until a failed request is retried.
|
||||
* @param props - original reasoning, translation preferences, and cancellation-aware callback.
|
||||
* @returns compact Markdown and its display-only translation controls.
|
||||
*/
|
||||
export function TranslationBody(props: TranslationBodyProps) {
|
||||
const { text, running, translate, t } = props
|
||||
const preferences = props.usePreferences(value => value)
|
||||
const locale = props.useTranslationLocale(value => value.active)
|
||||
const maxTextChars = props.useTranslationLimit(value => value)
|
||||
const targetLanguage = preferences.targetLanguage === 'auto' ? locale : preferences.targetLanguage
|
||||
const [state, setState] = useState<TranslationState>({ text, pending: false, failed: false })
|
||||
const [original, setOriginal] = useState(false)
|
||||
const translation = useRef<ReasoningTranslation>()
|
||||
useEffect(() => {
|
||||
const controller = new ReasoningTranslation({ provider: preferences.provider, targetLanguage }, maxTextChars, translate, setState)
|
||||
translation.current = controller
|
||||
return () => { controller.dispose(); translation.current = undefined }
|
||||
}, [preferences.provider, targetLanguage, maxTextChars, translate])
|
||||
useEffect(() => { translation.current?.update(text, running) },
|
||||
[text, running, preferences.provider, targetLanguage, maxTextChars, translate])
|
||||
const labels = useMemo(() => ({ code: { copyLabel: t('copy'), copiedLabel: t('copied'),
|
||||
toolbarLabels: { codeLabel: t('codeTitle'), wrapLabel: t('wrap'), unwrapLabel: t('unwrap') } }, footnotes: t('footnotes') }), [t])
|
||||
const showOriginal = original || state.failed
|
||||
return <div data-cot-translation="true" data-translation-state={state.failed ? 'failed' : state.pending ? 'pending' : 'ready'}
|
||||
data-translation-view={showOriginal ? 'original' : 'translated'}>
|
||||
<div className={css.toolbar}>
|
||||
<Button size="sm" variant="ghost" aria-pressed={!showOriginal} disabled={state.failed}
|
||||
onClick={() => { setOriginal(false) }}>{t('translation')}</Button>
|
||||
<Button size="sm" variant="ghost" aria-pressed={showOriginal} onClick={() => { setOriginal(true) }}>{t('original')}</Button>
|
||||
{state.pending && <span role="status" aria-label={t('translating')}><IconLoadingOutlineRegular size={14} className={css.spinner} /></span>}
|
||||
{state.failed && <><span className={css.failure} role="status">{t('failed')}</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => { translation.current?.retry() }}>{t('retry')}</Button></>}
|
||||
</div>
|
||||
<MarkdownText text={showOriginal ? text : state.text} streaming={running} labels={labels} variant="compact" />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Provider, language, and external-service disclosure on the Plugins page. */
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
||||
import { useId } from 'react'
|
||||
import { Button, SettingsForm, SettingsValueField, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { NS, formLabels } from './locales.ts'
|
||||
import type { TranslationFormInjected } from './preferences-form.ts'
|
||||
import css from './Translation.module.css'
|
||||
|
||||
export type TranslationSettingsProps = PropsRuntime<'plugins.bundle.config'> & PropsLocale<typeof NS> & InjectFace<TranslationFormInjected>
|
||||
|
||||
/**
|
||||
* Render staged translation preferences and name which text leaves the Host.
|
||||
* @param props - current draft fields, accepted-value metadata, and save actions.
|
||||
* @returns the bundle's translation preferences.
|
||||
*/
|
||||
export function TranslationSettings(props: TranslationSettingsProps) {
|
||||
const { t } = props
|
||||
const providerId = useId(), languageId = useId()
|
||||
const state = props.useTranslationForm(value => value)
|
||||
const disabled = !state.writable || state.saving
|
||||
return <SettingsForm labels={formLabels(t)} state={state} onSave={props.save} onDiscard={props.discard}>
|
||||
<p className={css.notice}>{t('privacy')}</p>
|
||||
<div className={css.field}>
|
||||
<div className={css.toolbar}>
|
||||
<label htmlFor={providerId}>{t('provider')}</label>
|
||||
{state.provider.overridden && <><Tag tone="neutral">{t('overridden')}</Tag>
|
||||
<Button size="sm" disabled={disabled} onClick={() => { props.resetField('provider') }}>{t('reset')}</Button></>}
|
||||
</div>
|
||||
<select id={providerId} value={state.provider.text} disabled={disabled} aria-invalid={state.provider.invalid || undefined}
|
||||
onChange={(event) => { props.edit('provider', event.target.value) }}>
|
||||
<option value="bing">{t('bing')}</option>
|
||||
<option value="google">{t('google')}</option>
|
||||
</select>
|
||||
{state.provider.invalid && <p role="status">{t('invalidProvider')}</p>}
|
||||
</div>
|
||||
<SettingsValueField id={languageId} label={t('targetLanguage')} hint={t('targetLanguageHint')}
|
||||
{...state.targetLanguage} overriddenLabel={t('overridden')} resetLabel={t('reset')} invalidLabel={t('invalidLanguage')}
|
||||
disabled={disabled} onEdit={(value) => { props.edit('targetLanguage', value) }} onReset={() => { props.resetField('targetLanguage') }} />
|
||||
</SettingsForm>
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Browser entry for optional translation of expanded reasoning. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import remote from '@deepseek-ai/dsh-experimental-client-ui-cot-translation/remote'
|
||||
import { mountTranslation } from './mount.ts'
|
||||
|
||||
export { inject } from './mount.ts'
|
||||
export type { CotTranslationPreferences } from '../preferences.ts'
|
||||
export type { CotTranslationKey } from './locales.ts'
|
||||
export type { TranslationBodyInjected, TranslationBodyProps } from './TranslationBody.tsx'
|
||||
export type { TranslationFormInjected, TranslationFormState } from './preferences-form.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap { cotTranslation: import('./locales.ts').CotTranslationKey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the experimental reasoning presentation and its Remote methods.
|
||||
* @param ctx - browser plugin context.
|
||||
* @returns disposer joining UI and Remote withdrawal.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<() => Promise<void>> { return await mountTranslation(ctx, remote) }
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Locale-owned copy for reasoning translation and its preferences. */
|
||||
import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/** Dictionary namespace for this plugin. */
|
||||
export const NS = 'cotTranslation'
|
||||
|
||||
/** English copy and key source. */
|
||||
export const en = {
|
||||
provider: 'Translation service', google: 'Google', bing: 'Bing', targetLanguage: 'Target language',
|
||||
targetLanguageHint: 'auto follows the UI language; language codes such as zh, en, and ja also work.',
|
||||
privacy: 'Expanded reasoning is sent to the selected service. It may contain private code or conversation details.',
|
||||
translation: 'Translation', original: 'Original', retry: 'Retry', failed: 'Translation failed. The original is still available.',
|
||||
translating: 'Translating reasoning', invalidProvider: 'Choose a translation service.', invalidLanguage: 'Enter auto or a language code, such as zh, en, or ja.',
|
||||
overridden: 'Overridden', reset: 'Reset to default',
|
||||
unavailable: 'Translation preferences are unavailable.', readOnly: 'This deployment stores settings read-only.',
|
||||
saveFailed: 'The deployment did not accept these values; they were left for you to correct.', save: 'Save', saving: 'Saving…',
|
||||
copy: 'Copy', copied: 'Copied', codeTitle: 'Code block', wrap: 'Wrap lines', unwrap: 'Unwrap lines', footnotes: 'Footnotes',
|
||||
}
|
||||
|
||||
/** Locale dictionary key union. */
|
||||
export type CotTranslationKey = keyof typeof en
|
||||
|
||||
/** Chinese copy. */
|
||||
export const zh: Record<CotTranslationKey, string> = {
|
||||
provider: '翻译服务', google: 'Google', bing: 'Bing', targetLanguage: '目标语言',
|
||||
targetLanguageHint: 'auto 使用界面语言;也可填写语言代码,例如 zh、en、ja',
|
||||
privacy: '展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。',
|
||||
translation: '译文', original: '原文', retry: '重试', failed: '翻译失败,已保留原文',
|
||||
translating: '正在翻译思考内容', invalidProvider: '请选择翻译服务', invalidLanguage: '请填写 auto 或语言代码,例如 zh、en、ja',
|
||||
overridden: '已覆盖', reset: '恢复默认',
|
||||
unavailable: '翻译设置暂不可用', readOnly: '本部署的设置为只读',
|
||||
saveFailed: '本部署没有接受这些值,已保留供你修改', save: '保存', saving: '保存中…',
|
||||
copy: '复制', copied: '已复制', codeTitle: '代码块', wrap: '自动换行', unwrap: '不换行', footnotes: '脚注',
|
||||
}
|
||||
|
||||
/**
|
||||
* Read shared form chrome from the plugin dictionary.
|
||||
* @param t - current locale reader.
|
||||
* @returns localized form labels.
|
||||
*/
|
||||
export function formLabels(t: (key: CotTranslationKey) => string): SettingsFormLabels {
|
||||
return { unavailable: t('unavailable'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/** Optional Remote contribution and reasoning-slot registration lifecycle. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-client-ui-cot-translation/remote'
|
||||
import type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { CotTranslationPreferences, CotTranslationSnapshot } from '../preferences.ts'
|
||||
import { TranslationBody, type TranslationBodyInjected } from './TranslationBody.tsx'
|
||||
import { TranslationSettings } from './TranslationSettings.tsx'
|
||||
import { TranslationPreferencesForm } from './preferences-form.ts'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
|
||||
/** Services required to mount the optional Remote and UI contributions. */
|
||||
export const inject = ['remote', 'slots', 'locale', 'configForms']
|
||||
|
||||
async function registerUi(ctx: Context, lifetime: AbortController): Promise<void> {
|
||||
let readTask: Promise<RemoteResult<CotTranslationSnapshot>> | undefined
|
||||
let updating: Promise<void> | undefined
|
||||
let revision = 0, lastReadRevision = -1, acceptedRevision = -1
|
||||
const isDisposed = (): boolean => lifetime.signal.aborted || ctx.fiber.uid === null
|
||||
ctx.effect(() => async () => {
|
||||
lifetime.abort()
|
||||
await Promise.allSettled([readTask, updating])
|
||||
}, 'cot-translation: metadata queries')
|
||||
const invalidate = (): void => { revision += 1; refresh() }
|
||||
ctx.effect(() => ctx.remote.$on('settings/document-updated', invalidate))
|
||||
ctx.on('connection/reset', invalidate)
|
||||
const read = async (): Promise<RemoteResult<CotTranslationSnapshot> | undefined> => {
|
||||
while (true) {
|
||||
const observed = revision
|
||||
lastReadRevision = observed
|
||||
readTask = ctx.remote.cotTranslation.limits(lifetime.signal)
|
||||
const result = await readTask
|
||||
if (isDisposed()) return undefined
|
||||
if (observed === revision) return result
|
||||
}
|
||||
}
|
||||
let initial: RemoteResult<CotTranslationSnapshot> | undefined
|
||||
try { initial = await read() } catch (error) {
|
||||
if (isDisposed()) return
|
||||
throw error
|
||||
}
|
||||
if (initial === undefined) return
|
||||
if (!initial.ok) throw initial.error
|
||||
const preferences = createSnapshotStore(initial.value.preferences)
|
||||
const translationLimit = createSnapshotStore(initial.value.maxTextChars)
|
||||
acceptedRevision = lastReadRevision
|
||||
function refresh(): void {
|
||||
if (updating !== undefined || acceptedRevision < 0 || isDisposed()) return
|
||||
updating = (async () => {
|
||||
try {
|
||||
const result = await read()
|
||||
if (result?.ok) {
|
||||
preferences.set(result.value.preferences)
|
||||
translationLimit.set(result.value.maxTextChars)
|
||||
acceptedRevision = lastReadRevision
|
||||
}
|
||||
} catch (_error) {
|
||||
if (!isDisposed()) ctx.logger.warn('cot-translation: could not refresh Host preferences and request limits')
|
||||
}
|
||||
})().finally(() => {
|
||||
updating = undefined
|
||||
if (revision !== lastReadRevision && !isDisposed()) refresh()
|
||||
})
|
||||
}
|
||||
const scope = ctx.configForms.get<CotTranslationPreferences>('cot-translation')
|
||||
ctx.effect(() => scope.subscribe(invalidate))
|
||||
ctx.effect(() => ctx.locale.register(NS, { en, zh }))
|
||||
const form = new TranslationPreferencesForm(scope)
|
||||
ctx.effect(() => () => { form.dispose() })
|
||||
const injected = (sessionId: SessionId): TranslationBodyInjected => ({
|
||||
hooks: { preferences, translationLocale: ctx.locale, translationLimit },
|
||||
translate: async (request, signal) => {
|
||||
if (acceptedRevision !== revision) refresh()
|
||||
while (updating !== undefined) await updating
|
||||
signal.throwIfAborted()
|
||||
const accepted = preferences.getSnapshot()
|
||||
if (isDisposed() || acceptedRevision !== revision || request.provider !== accepted.provider
|
||||
|| (accepted.targetLanguage !== 'auto' && request.targetLanguage !== accepted.targetLanguage)
|
||||
|| request.text.length > translationLimit.getSnapshot()) throw new Error('Translation preferences or limits changed')
|
||||
const result = await ctx.remote.cotTranslation.translate({ ...request, sessionId }, signal)
|
||||
if (!result.ok) throw result.error
|
||||
return result.value
|
||||
},
|
||||
})
|
||||
ctx.slots.inject('conversation.chat.reasoning-body', () => ctx.slots.register({
|
||||
name: 'conversation.chat.reasoning-body', locale: NS, select: owner => owner,
|
||||
inject: injected,
|
||||
}, TranslationBody))
|
||||
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
|
||||
name: 'plugins.bundle.config', key: '@deepseek-ai/dsh-experimental-cot-translation-bundle', locale: NS, inject: () => form.inject(),
|
||||
}, TranslationSettings))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount Session-bound translation and withdraw UI before its generated Remote.
|
||||
* @param ctx - Client plugin lifetime.
|
||||
* @param contribution - generated controller Remote definitions.
|
||||
* @returns disposer that removes both contributions.
|
||||
*/
|
||||
export async function mountTranslation(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise<void>> {
|
||||
const lifetime = new AbortController()
|
||||
// Effect cleanup waits for pending setup; uid clearance cancels its query first.
|
||||
ctx.on('internal/plugin', (fiber) => { if (fiber === ctx.fiber && fiber.uid === null) lifetime.abort() })
|
||||
ctx.effect(() => () => { lifetime.abort() }, 'cot-translation: query cancellation')
|
||||
const unmountRemote = await ctx.remote.$mount(contribution)
|
||||
const ui = ctx.inject(['remote.cotTranslation', ...inject.slice(1)], child => registerUi(child, lifetime))
|
||||
try { await ui } catch (error) { await ui.dispose(); await unmountRemote(); throw error }
|
||||
return async () => { await ui.dispose(); await unmountRemote() }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Staged translation preferences using the shared Plugins form model. */
|
||||
import { SettingsFormModel, settingsTextField, type SettingsFieldState, type SettingsFormActions,
|
||||
type SettingsFormScope, type SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { LANGUAGE_PREFERENCE_PATTERN, type CotTranslationPreferences } from '../preferences.ts'
|
||||
|
||||
/** Current drafts and the shared form status. */
|
||||
export interface TranslationFormState extends SettingsFormShell {
|
||||
provider: SettingsFieldState
|
||||
targetLanguage: SettingsFieldState
|
||||
}
|
||||
|
||||
/** Registration-side form observable and edit callbacks. */
|
||||
export interface TranslationFormInjected extends SettingsFormActions {
|
||||
hooks: { translationForm: SnapshotStore<TranslationFormState> }
|
||||
}
|
||||
|
||||
/** Stages provider and language edits until the reader saves them. */
|
||||
export class TranslationPreferencesForm {
|
||||
private readonly form: SettingsFormModel<CotTranslationPreferences>
|
||||
private readonly state: SnapshotStore<TranslationFormState>
|
||||
|
||||
/** @param scope - Host-backed configuration form for this plugin. */
|
||||
constructor(scope: SettingsFormScope<CotTranslationPreferences>) {
|
||||
this.form = new SettingsFormModel(scope, [{
|
||||
...settingsTextField('provider'),
|
||||
parse: (text) => {
|
||||
const provider = text.trim()
|
||||
return provider === '' ? { kind: 'clear' }
|
||||
: provider === 'bing' || provider === 'google' ? { kind: 'set', value: provider } : undefined
|
||||
},
|
||||
}, {
|
||||
...settingsTextField('targetLanguage'),
|
||||
parse: text => LANGUAGE_PREFERENCE_PATTERN.test(text.trim())
|
||||
? { kind: 'set', value: text.trim() } : undefined,
|
||||
}])
|
||||
this.state = this.form.bind(() => ({ ...this.form.shell(), provider: this.form.field('provider'),
|
||||
targetLanguage: this.form.field('targetLanguage') }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the registration face for the preferences form.
|
||||
* @returns draft observable and shared edit/save actions.
|
||||
*/
|
||||
inject(): TranslationFormInjected { return { hooks: { translationForm: this.state }, ...this.form.actions() } }
|
||||
/** Release the accepted-preference subscription. */
|
||||
dispose(): void { this.form.dispose() }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/** Sequential paragraph translation for one expanded reasoning disclosure. */
|
||||
import type { TranslationRequest } from '@deepseek-ai/dsh-experimental-translator/types'
|
||||
|
||||
/** One source fragment and its untranslated separator. */
|
||||
interface Fragment { text: string; suffix: string; complete: boolean }
|
||||
|
||||
/** Display-only result retained while this reasoning disclosure is mounted. */
|
||||
export interface TranslationState { text: string; pending: boolean; failed: boolean }
|
||||
|
||||
/** Cancellation-aware callback supplied by the Remote consumer. */
|
||||
export type TranslateText = (request: TranslationRequest, signal: AbortSignal) => Promise<string>
|
||||
|
||||
/**
|
||||
* Split reasoning into completed paragraphs and request-sized fragments.
|
||||
* @param text - original reasoning, including its exact whitespace.
|
||||
* @param running - whether the final paragraph may still grow.
|
||||
* @param maxTextChars - translator's configured UTF-16 request limit.
|
||||
* @returns whitespace-preserving fragments with completion flags for translation eligibility.
|
||||
*/
|
||||
export function reasoningFragments(text: string, running: boolean, maxTextChars: number): Fragment[] {
|
||||
const result: Fragment[] = []
|
||||
const separator = /\r?\n(?:[\t ]*\r?\n)+/g
|
||||
let start = 0
|
||||
while (true) {
|
||||
const match = separator.exec(text)
|
||||
let remaining = text.slice(start, match?.index ?? text.length)
|
||||
while (remaining.length > maxTextChars) {
|
||||
let cut = maxTextChars
|
||||
if (/[\uD800-\uDBFF]/.test(remaining.charAt(cut - 1))) cut -= 1
|
||||
const whitespace = remaining.slice(0, cut).search(/\s+\S*$/)
|
||||
if (whitespace > 0) cut = whitespace
|
||||
const source = remaining.slice(0, cut)
|
||||
remaining = remaining.slice(cut)
|
||||
const suffix = remaining.match(/^\s+/)?.[0] ?? ''
|
||||
remaining = remaining.slice(suffix.length)
|
||||
result.push({ text: source, suffix, complete: true })
|
||||
}
|
||||
result.push({ text: remaining, suffix: match?.[0] ?? '', complete: match !== null || !running })
|
||||
if (match === null) return result
|
||||
start = match.index + match[0].length
|
||||
}
|
||||
}
|
||||
|
||||
/** One lifecycle controller owns cancellation, request ordering, and stale-result exclusion. */
|
||||
export class ReasoningTranslation {
|
||||
private readonly controller = new AbortController()
|
||||
private readonly cache = new Map<string, string>()
|
||||
private fragments: Fragment[] = []
|
||||
private pending = false
|
||||
private failed = false
|
||||
|
||||
/**
|
||||
* @param selection - explicit provider and resolved target language.
|
||||
* @param maxTextChars - configured translator request limit.
|
||||
* @param translate - one cancellation-aware Remote request.
|
||||
* @param publish - component-local display state update.
|
||||
*/
|
||||
constructor(
|
||||
private readonly selection: Pick<TranslationRequest, 'provider' | 'targetLanguage'>,
|
||||
private readonly maxTextChars: number,
|
||||
private readonly translate: TranslateText,
|
||||
private readonly publish: (state: TranslationState) => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Adopt the current source and enqueue newly completed fragments.
|
||||
* @param text - current original reasoning.
|
||||
* @param running - whether its tail is still streaming.
|
||||
*/
|
||||
update(text: string, running: boolean): void {
|
||||
this.fragments = reasoningFragments(text, running, this.maxTextChars)
|
||||
this.emit()
|
||||
void this.run()
|
||||
}
|
||||
|
||||
/** Retry untranslated fragments after a failed request. */
|
||||
retry(): void { this.failed = false; void this.run() }
|
||||
|
||||
/** Abort the disclosure's requests and suppress all later publications. */
|
||||
dispose(): void { this.controller.abort() }
|
||||
|
||||
private isDisposed(): boolean { return this.controller.signal.aborted }
|
||||
|
||||
private emit(): void {
|
||||
if (this.isDisposed()) return
|
||||
const text = this.fragments.map((fragment) => {
|
||||
const content = fragment.complete ? this.cache.get(fragment.text) ?? fragment.text : fragment.text
|
||||
return content + fragment.suffix
|
||||
}).join('')
|
||||
this.publish({ text, pending: this.pending, failed: this.failed })
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
if (this.pending || this.failed || this.isDisposed()) return
|
||||
let next = this.fragments.find(fragment => fragment.complete && fragment.text.trim() !== '' && !this.cache.has(fragment.text))
|
||||
if (next === undefined) return
|
||||
this.pending = true
|
||||
this.emit()
|
||||
try {
|
||||
while (next !== undefined) {
|
||||
const translated = await this.translate({ ...this.selection, text: next.text }, this.controller.signal)
|
||||
if (this.isDisposed()) return
|
||||
this.cache.set(next.text, translated)
|
||||
this.emit()
|
||||
next = this.fragments.find(fragment => fragment.complete && fragment.text.trim() !== '' && !this.cache.has(fragment.text))
|
||||
}
|
||||
} catch (_error) {
|
||||
if (!this.isDisposed()) this.failed = true
|
||||
} finally {
|
||||
this.pending = false
|
||||
this.emit()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** CSS module names emitted by the browser bundle. */
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/** Host access to machine translation of displayed reasoning. */
|
||||
import { addAbortListener } from 'node:events'
|
||||
import { Context, type Volatile } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {} from '@deepseek-ai/dsh-settings'
|
||||
import type {} from '@deepseek-ai/dsh-api-session-controller'
|
||||
import { TranslationError } from '@deepseek-ai/dsh-experimental-translator'
|
||||
import type { TranslationRequest, TranslationSpec } from '@deepseek-ai/dsh-experimental-translator/types'
|
||||
import { LANGUAGE_PREFERENCE_PATTERN } from './preferences.ts'
|
||||
import type { CotTranslationPreferences, CotTranslationSnapshot } from './types.ts'
|
||||
|
||||
/** Live preferences presented on the bundle's Plugins page. */
|
||||
export interface Config {
|
||||
/** Explicit translation provider, defaulting to Bing. */
|
||||
provider: Volatile<CotTranslationPreferences['provider']>
|
||||
/** Target language code; auto follows the browser UI locale. */
|
||||
targetLanguage: Volatile<string>
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Remote consumer of the experimental translation service. */
|
||||
cotTranslation: CotTranslationController
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional reasoning translation delegates Session-bound storage to the translator. */
|
||||
export default class CotTranslationController extends TypertRemoteService {
|
||||
static inject = ['translator', 'typert', 'sessionController']
|
||||
static Config = z.object({
|
||||
provider: z.union(['google', 'bing'] as const).default('bing').volatile(),
|
||||
targetLanguage: z.string().pattern(LANGUAGE_PREFERENCE_PATTERN).default('auto').volatile(),
|
||||
})
|
||||
|
||||
constructor(ctx: Context, private readonly config: Config) {
|
||||
super(ctx, 'cotTranslation', { namespace: 'cotTranslation' })
|
||||
ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Read accepted translation preferences and the current request limit without sending text.
|
||||
* @param signal - browser query cancellation or Remote contribution withdrawal.
|
||||
* @returns authoritative preferences and maximum UTF-16 text length per request.
|
||||
*/
|
||||
@Remote
|
||||
limits(signal: AbortSignal): CotTranslationSnapshot {
|
||||
signal.throwIfAborted()
|
||||
return { maxTextChars: this.ctx.translator.maxTextChars,
|
||||
preferences: { provider: this.config.provider.get(), targetLanguage: this.config.targetLanguage.get() } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one displayed fragment through the reader's selected provider.
|
||||
* @param request - original text and Session identity supplied by the Client; provider and explicit
|
||||
* target language match accepted preferences, while auto uses the browser locale.
|
||||
* @param signal - browser cancellation or Remote contribution withdrawal.
|
||||
* @returns translated text; an uncached inactive Session joins ordinary GUI activation before retry.
|
||||
* Failures omit the source and provider response. Cancellation stops this caller's wait, not shared activation.
|
||||
*/
|
||||
@Remote
|
||||
async translate(request: TranslationRequest, signal: AbortSignal): Promise<string> {
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const spec = this.resolveSelected(request)
|
||||
try {
|
||||
return await this.ctx.translator.translate(spec, signal)
|
||||
} catch (error) {
|
||||
if (!(error instanceof TranslationError) || error.code !== 'TRANSLATION_SESSION_INACTIVE' || spec.sessionId === undefined) throw error
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
const activation = this.ctx.sessionController.resolveAgent(spec.sessionId).then((result) => {
|
||||
if ('error' in result) throw result.error
|
||||
}).catch((error: unknown) => {
|
||||
if (signal.aborted) this.ctx.logger.warn('Reasoning translation Session activation failed after cancellation')
|
||||
throw error
|
||||
})
|
||||
const cancelled = Promise.withResolvers<never>()
|
||||
using _listener = addAbortListener(signal, () => {
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Preserve the caller's arbitrary cancellation reason.
|
||||
cancelled.reject(signal.reason)
|
||||
})
|
||||
await Promise.race([activation, cancelled.promise])
|
||||
signal.throwIfAborted()
|
||||
return await this.ctx.translator.translate(this.resolveSelected(spec), signal)
|
||||
} catch (_error) {
|
||||
signal.throwIfAborted()
|
||||
throw new RemoteError('cotTranslation/failed', 'Translation failed', {})
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSelected(request: TranslationRequest): TranslationSpec {
|
||||
const accepted = this.config.provider.get()
|
||||
const provider = request.provider ?? accepted
|
||||
if (provider !== accepted) throw new Error('Translation provider is not selected')
|
||||
const targetLanguage = this.config.targetLanguage.get()
|
||||
if (targetLanguage !== 'auto' && request.targetLanguage !== targetLanguage) throw new Error('Translation language is not selected')
|
||||
return this.ctx.translator.resolve({ ...request, provider })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Shared validation for Host configuration and browser preference drafts. */
|
||||
export type { CotTranslationPreferences, CotTranslationSnapshot } from './types.ts'
|
||||
|
||||
/** Language preference accepted by both Host configuration and browser drafts. */
|
||||
export const LANGUAGE_PREFERENCE_PATTERN = /^(?:auto|[a-zA-Z]{2,8}(?:-[a-zA-Z0-9]{1,8})*)$/
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Browser-safe translation preferences and Remote metadata. */
|
||||
import type {} from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { TranslationProvider } from '@deepseek-ai/dsh-experimental-translator/types'
|
||||
|
||||
/** Translation choices; auto targets the active browser UI language. */
|
||||
export interface CotTranslationPreferences {
|
||||
/** Explicit provider selection; requests never fall back to another provider. */
|
||||
provider: TranslationProvider
|
||||
/** BCP 47 language code, or auto to follow the UI language. */
|
||||
targetLanguage: string
|
||||
}
|
||||
|
||||
/** Authoritative Host metadata read before any reasoning is submitted. */
|
||||
export interface CotTranslationSnapshot {
|
||||
/** Maximum UTF-16 text length accepted by one translation request. */
|
||||
maxTextChars: number
|
||||
/** Current accepted provider and target-language preferences. */
|
||||
preferences: CotTranslationPreferences
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface RemoteErrorDetailsMap {
|
||||
/** Provider or input failure, without original text or response details. */
|
||||
'cotTranslation/failed': Record<string, never>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/** Translation joins the GUI's ordinary Agent ownership without creating a model turn. */
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import SessionController from '@deepseek-ai/dsh-api-session-controller'
|
||||
import Translator from '@deepseek-ai/dsh-experimental-translator'
|
||||
import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionQuery from '@deepseek-ai/dsh-session-query'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import CotTranslationController from '../src/index.ts'
|
||||
|
||||
/** Real stored-session reads; this fixture does not provide full-text search. */
|
||||
class ReadOnlySessionQuery extends SessionQuery {
|
||||
searchSessions(): never { throw new Error('Unexpected full-text Session search') }
|
||||
searchEvents(): never { throw new Error('Unexpected full-text event search') }
|
||||
}
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function fixture(origin?: 'subagent') {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cot-activation-'))
|
||||
cleanups.push(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
const requests: string[] = []
|
||||
const server = createServer((request, response) => {
|
||||
const chunks: Buffer[] = []
|
||||
request.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
request.on('end', () => {
|
||||
requests.push(Buffer.concat(chunks).toString('utf8'))
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify([{ translations: [{ text: 'Saved translation', to: 'zh-Hans' }] }]))
|
||||
})
|
||||
})
|
||||
cleanups.push(async () => {
|
||||
const closed = new Promise<void>((resolve, reject) => server.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
}))
|
||||
server.closeAllConnections()
|
||||
await closed
|
||||
})
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('Expected allocated loopback listener')
|
||||
|
||||
const ctx = new Context()
|
||||
cleanups.push(async () => { await ctx.fiber.dispose() })
|
||||
await mountAgentLoopTestDependencies(ctx, { workingDirectory: true })
|
||||
await ctx.plugin(JsonlPersistence, { root: join(root, 'sessions'), compression: 'none' })
|
||||
await ctx.plugin(TypertRegistry)
|
||||
await ctx.plugin(ReadOnlySessionQuery, {})
|
||||
await mountAgentLoopTestHarness(ctx)
|
||||
ctx.provide('agentDefaultModel', {
|
||||
currentSelection: () => ({ provider: 'unused', model: 'unused' }),
|
||||
saveSelection: async () => {},
|
||||
} as never)
|
||||
ctx.provide('fileUploads', { registerAgentResolver: () => () => {} } as never)
|
||||
const sessions = new SessionController(ctx, { nativeOpen: false })
|
||||
await ctx.plugin(Translator, Translator.Config({ bingEndpoint: `http://127.0.0.1:${address.port}/bing` }))
|
||||
const ui = ctx.plugin(CotTranslationController, {})
|
||||
await ui
|
||||
|
||||
const id = SessionId('saved-conversation')
|
||||
const original = [
|
||||
{ type: 'turn/start' as const, seq: SessionSeq(0), time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end' as const, seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } },
|
||||
]
|
||||
await using writer = await ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION, id, createdAt: 1, cwd: root, isSeeded: false,
|
||||
delegationDepth: origin === undefined ? 0 : 1,
|
||||
...origin === undefined ? {} : { origin, parentSession: SessionId('parent-conversation') },
|
||||
})
|
||||
await writer.append(original)
|
||||
await writer.flush()
|
||||
return { ctx, id, original, sessions, ui, requests }
|
||||
}
|
||||
|
||||
it('shares normal GUI activation and leaves the resumed Agent owned by the GUI after translation is disabled', async () => {
|
||||
const b = await fixture()
|
||||
const entered = Promise.withResolvers<undefined>(), release = Promise.withResolvers<undefined>()
|
||||
cleanups.push(async () => { release.resolve(undefined) })
|
||||
const open = b.ctx.sessionPersistence.open.bind(b.ctx.sessionPersistence)
|
||||
const writerOpen = vi.spyOn(b.ctx.sessionPersistence, 'open').mockImplementation(async (id, access, options) => {
|
||||
if (access === 'write') { entered.resolve(undefined); await release.promise }
|
||||
return open(id, access, options)
|
||||
})
|
||||
const startups: Array<{ id: SessionId; source: string }> = []
|
||||
b.ctx.on('agent/created', ({ agent, source }) => { startups.push({ id: agent.id, source }) })
|
||||
const stream = vi.spyOn(b.ctx.llm, 'stream')
|
||||
const request = { text: 'Original reasoning', targetLanguage: 'zh', sessionId: b.id }
|
||||
const translation = b.ctx.cotTranslation.translate(request, new AbortController().signal)
|
||||
await entered.promise
|
||||
expect(b.requests).toEqual([])
|
||||
expect(b.ctx.agents.get(b.id)).toBeUndefined()
|
||||
|
||||
const followAbort = new AbortController()
|
||||
const follow = b.sessions.follow({ address: { kind: 'session', sessionId: b.id } }, followAbort.signal)[Symbol.asyncIterator]()
|
||||
cleanups.push(async () => { followAbort.abort(); await follow.return?.() })
|
||||
await expect(follow.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
|
||||
const following = follow.next()
|
||||
const resumed = b.sessions.resolveAgent(b.id)
|
||||
release.resolve(undefined)
|
||||
await expect(translation).resolves.toBe('Saved translation')
|
||||
const result = await resumed
|
||||
if ('error' in result) throw result.error
|
||||
followAbort.abort()
|
||||
await following
|
||||
await follow.return?.()
|
||||
|
||||
expect(startups).toEqual([{ id: b.id, source: 'resume' }])
|
||||
expect(writerOpen.mock.calls.filter(([, access]) => access === 'write')).toHaveLength(1)
|
||||
expect(stream).not.toHaveBeenCalled()
|
||||
expect(b.requests).toEqual(['["Original reasoning"]'])
|
||||
const owner = b.ctx.agents.get(b.id)
|
||||
expect(owner?.id).toBe(result.agent.id)
|
||||
await b.ui.dispose()
|
||||
expect(b.ctx.agents.get(b.id)).toBe(owner)
|
||||
expect(result.agent.status).toBe('idle')
|
||||
await using saved = await b.ctx.sessionPersistence.open(b.id, 'read')
|
||||
const events = (await saved.read()).events
|
||||
expect(events.slice(0, b.original.length)).toEqual(b.original)
|
||||
expect(events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(events.filter(event => event.type.startsWith('plugin:translator/')).map(event => event.type))
|
||||
.toEqual(['plugin:translator/request', 'plugin:translator/result'])
|
||||
})
|
||||
|
||||
it('keeps an uncached inactive subagent unchanged when ordinary Session activation refuses ownership', async () => {
|
||||
const b = await fixture('subagent')
|
||||
const open = vi.spyOn(b.ctx.sessionPersistence, 'open')
|
||||
const resume = vi.spyOn(b.ctx.agents, 'resume')
|
||||
const stream = vi.spyOn(b.ctx.llm, 'stream')
|
||||
const failure = await b.ctx.cotTranslation.translate({
|
||||
text: 'Original child reasoning', targetLanguage: 'zh', sessionId: b.id,
|
||||
}, new AbortController().signal).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(RemoteError)
|
||||
expect(failure).toMatchObject({ code: 'cotTranslation/failed', message: 'Translation failed', details: {} })
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(stream).not.toHaveBeenCalled()
|
||||
expect(b.requests).toEqual([])
|
||||
expect(b.ctx.agents.get(b.id)).toBeUndefined()
|
||||
expect(b.ctx.sessions.get(b.id)).toBeUndefined()
|
||||
expect(open.mock.calls.every(([, access]) => access === 'read')).toBe(true)
|
||||
await using saved = await b.ctx.sessionPersistence.open(b.id, 'read')
|
||||
expect((await saved.read()).events).toEqual(b.original)
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
/** Remote translation enforces accepted routing and sanitizes provider failures. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { TranslationError } from '@deepseek-ai/dsh-experimental-translator'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { TranslationRequest } from '@deepseek-ai/dsh-experimental-translator/types'
|
||||
import type { CotTranslationPreferences } from '../src/preferences.ts'
|
||||
import CotTranslationController from '../src/index.ts'
|
||||
|
||||
const roots: Context[] = []
|
||||
afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) })
|
||||
|
||||
function fixture(preferences: Partial<CotTranslationPreferences> = {}) {
|
||||
const ctx = new Context(); roots.push(ctx)
|
||||
const resolve = vi.fn((request: TranslationRequest) => ({ sourceLanguage: 'auto', ...request }))
|
||||
const translate = vi.fn(async () => 'translated')
|
||||
ctx.provide('translator', { maxTextChars: 32, resolve, translate } as never)
|
||||
const configure = vi.fn(() => () => {})
|
||||
ctx.provide('settings', { configure } as never)
|
||||
const resolveAgent = vi.fn<Context['sessionController']['resolveAgent']>(async () => ({ agent: {} as never }))
|
||||
ctx.provide('sessionController', { resolveAgent } as never)
|
||||
const config = CotTranslationController.Config(preferences)
|
||||
const controller = new CotTranslationController(ctx, config)
|
||||
return { ctx, controller, resolve, translate, configure, resolveAgent, config }
|
||||
}
|
||||
|
||||
it('exposes configured limits without translating and uses explicit provider and target', async () => {
|
||||
const b = fixture({ provider: 'google' }), request = {
|
||||
text: 'original', targetLanguage: 'zh', provider: 'google' as const, sessionId: SessionId('source'),
|
||||
}
|
||||
expect(b.controller.limits(new AbortController().signal)).toEqual({ maxTextChars: 32,
|
||||
preferences: { provider: 'google', targetLanguage: 'auto' } })
|
||||
expect(b.translate).not.toHaveBeenCalled()
|
||||
const signal = new AbortController().signal
|
||||
expect(await b.controller.translate(request, signal)).toBe('translated')
|
||||
expect(b.resolve).toHaveBeenCalledWith(request)
|
||||
expect(b.translate).toHaveBeenCalledWith({ sourceLanguage: 'auto', ...request }, signal)
|
||||
expect(b.resolveAgent).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(b.configure).toHaveBeenCalledWith({ auto: false }, expect.anything()) })
|
||||
})
|
||||
|
||||
const scoped = { text: 'original', targetLanguage: 'zh', sessionId: SessionId('source') }
|
||||
const inactive = () => new TranslationError('TRANSLATION_SESSION_INACTIVE', 'Session is inactive')
|
||||
|
||||
it('joins ordinary Session activation only for an uncached inactive Session and retries once', async () => {
|
||||
const b = fixture(), signal = new AbortController().signal
|
||||
b.translate.mockRejectedValueOnce(inactive())
|
||||
await expect(b.controller.translate(scoped, signal)).resolves.toBe('translated')
|
||||
expect(b.resolveAgent).toHaveBeenCalledExactlyOnceWith(scoped.sessionId)
|
||||
expect(b.translate).toHaveBeenCalledTimes(2)
|
||||
expect(b.translate.mock.calls[0]).toEqual(b.translate.mock.calls[1])
|
||||
|
||||
b.translate.mockRejectedValue(inactive())
|
||||
await expect(b.controller.translate(scoped, signal)).rejects.toMatchObject({ code: 'cotTranslation/failed' })
|
||||
expect(b.resolveAgent).toHaveBeenCalledTimes(2)
|
||||
expect(b.translate).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ error: new TranslationError('TRANSLATION_STORAGE_ERROR', 'unavailable'), request: scoped },
|
||||
{ error: Object.assign(new Error('untyped failure'), { code: 'TRANSLATION_SESSION_INACTIVE' }), request: scoped },
|
||||
{ error: inactive(), request: { text: 'original', targetLanguage: 'zh' } },
|
||||
])('does not activate for an unrelated failure or a stateless request: $error', async ({ error, request }) => {
|
||||
const b = fixture()
|
||||
b.translate.mockRejectedValue(error)
|
||||
await expect(b.controller.translate(request, new AbortController().signal)).rejects.toMatchObject({ code: 'cotTranslation/failed' })
|
||||
expect(b.resolveAgent).not.toHaveBeenCalled()
|
||||
expect(b.translate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['return', 'throw'] as const)('redacts an activation failure without retrying: %s', async (outcome) => {
|
||||
const b = fixture(), error = new RemoteError('gateway/internal', 'private startup output', {})
|
||||
b.translate.mockRejectedValueOnce(inactive())
|
||||
if (outcome === 'return') b.resolveAgent.mockResolvedValueOnce({ error })
|
||||
else b.resolveAgent.mockRejectedValueOnce(error)
|
||||
await expect(b.controller.translate(scoped, new AbortController().signal)).rejects.toMatchObject({
|
||||
code: 'cotTranslation/failed', message: 'Translation failed', details: {},
|
||||
})
|
||||
expect(b.translate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['provider', 'targetLanguage'] as const)('rechecks accepted %s after activation before sending text', async (preference) => {
|
||||
const b = fixture(), activation = Promise.withResolvers<Awaited<ReturnType<typeof b.resolveAgent>>>()
|
||||
b.translate.mockRejectedValueOnce(inactive())
|
||||
b.resolveAgent.mockReturnValueOnce(activation.promise)
|
||||
const task = b.controller.translate(scoped, new AbortController().signal)
|
||||
await vi.waitFor(() => { expect(b.resolveAgent).toHaveBeenCalledOnce() })
|
||||
Object.assign(b.config, CotTranslationController.Config(preference === 'provider' ? { provider: 'google' } : { targetLanguage: 'ja' }))
|
||||
activation.resolve({ agent: {} as never })
|
||||
await expect(task).rejects.toMatchObject({ code: 'cotTranslation/failed' })
|
||||
expect(b.translate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['success', 'failure'] as const)('cancels the caller promptly while shared activation later settles: %s', async (outcome) => {
|
||||
const b = fixture(), activation = Promise.withResolvers<Awaited<ReturnType<typeof b.resolveAgent>>>()
|
||||
const warning = vi.spyOn(b.ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const lifetime = new AbortController(), reason = new Error('reader closed')
|
||||
b.translate.mockRejectedValueOnce(inactive())
|
||||
b.resolveAgent.mockReturnValueOnce(activation.promise)
|
||||
const task = b.controller.translate(scoped, lifetime.signal)
|
||||
await vi.waitFor(() => { expect(b.resolveAgent).toHaveBeenCalledOnce() })
|
||||
lifetime.abort(reason)
|
||||
await expect(task).rejects.toBe(reason)
|
||||
expect(b.translate).toHaveBeenCalledOnce()
|
||||
if (outcome === 'success') activation.resolve({ agent: {} as never })
|
||||
else activation.reject(new Error('private startup output'))
|
||||
await activation.promise.catch(() => {})
|
||||
if (outcome === 'failure') {
|
||||
await vi.waitFor(() => { expect(warning).toHaveBeenCalledExactlyOnceWith('Reasoning translation Session activation failed after cancellation') })
|
||||
} else expect(warning).not.toHaveBeenCalled()
|
||||
expect(b.translate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves cancellation that arrives with the inactive result before starting activation', async () => {
|
||||
const b = fixture(), lifetime = new AbortController(), reason = new Error('reader closed')
|
||||
b.translate.mockImplementationOnce(async () => { lifetime.abort(reason); throw inactive() })
|
||||
await expect(b.controller.translate(scoped, lifetime.signal)).rejects.toBe(reason)
|
||||
expect(b.resolveAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dispatch after activation resolves at the same time as cancellation', async () => {
|
||||
const b = fixture(), lifetime = new AbortController(), reason = new Error('reader closed')
|
||||
b.translate.mockRejectedValueOnce(inactive())
|
||||
b.resolveAgent.mockImplementationOnce(async () => { lifetime.abort(reason); return { agent: {} as never } })
|
||||
await expect(b.controller.translate(scoped, lifetime.signal)).rejects.toBe(reason)
|
||||
expect(b.translate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ accepted: 'bing' as const, requested: 'google' as const },
|
||||
{ accepted: 'google' as const, requested: 'bing' as const },
|
||||
])('rejects $requested when the accepted provider is $accepted before lookup or dispatch', async ({ accepted, requested }) => {
|
||||
const b = fixture({ provider: accepted })
|
||||
await expect(b.controller.translate({ text: 'private original', targetLanguage: 'ja', provider: requested }, new AbortController().signal))
|
||||
.rejects.toMatchObject({ code: 'cotTranslation/failed', message: 'Translation failed', details: {} })
|
||||
expect(b.resolve).not.toHaveBeenCalled()
|
||||
expect(b.translate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redacts provider error text and preserves cancellation before and during translation', async () => {
|
||||
const b = fixture(), request = { text: 'private original', targetLanguage: 'ja' }
|
||||
b.translate.mockRejectedValueOnce(new Error('private provider response'))
|
||||
await expect(b.controller.translate(request, new AbortController().signal)).rejects.toMatchObject({
|
||||
code: 'cotTranslation/failed', message: 'Translation failed', details: {},
|
||||
})
|
||||
const abort = new Error('cancelled')
|
||||
await expect(b.controller.translate(request, AbortSignal.abort(abort))).rejects.toBe(abort)
|
||||
const pending = Promise.withResolvers<string>(), lifetime = new AbortController()
|
||||
b.translate.mockImplementationOnce(() => pending.promise)
|
||||
const task = b.controller.translate(request, lifetime.signal)
|
||||
lifetime.abort(abort); pending.reject(new Error('provider aborted'))
|
||||
await expect(task).rejects.toBe(abort)
|
||||
})
|
||||
|
||||
it('rejects a different explicit target before saved-result lookup or provider dispatch', async () => {
|
||||
const b = fixture({ targetLanguage: 'ja' })
|
||||
await expect(b.controller.translate({ text: 'private original', targetLanguage: 'zh' }, new AbortController().signal))
|
||||
.rejects.toMatchObject({ code: 'cotTranslation/failed', message: 'Translation failed', details: {} })
|
||||
expect(b.resolve).not.toHaveBeenCalled()
|
||||
expect(b.translate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates preference codes while providing volatile Bing and automatic language defaults', () => {
|
||||
const defaults = CotTranslationController.Config({})
|
||||
expect(defaults.provider.get()).toBe('bing')
|
||||
expect(defaults.targetLanguage.get()).toBe('auto')
|
||||
expect(() => CotTranslationController.Config({ targetLanguage: 'invalid language' })).toThrow()
|
||||
})
|
||||
|
||||
it('reports accepted Host preferences and uses them when a request omits its provider', async () => {
|
||||
const b = fixture({ provider: 'google', targetLanguage: 'ja' }), signal = new AbortController().signal
|
||||
expect(b.controller.limits(signal)).toEqual({ maxTextChars: 32, preferences: { provider: 'google', targetLanguage: 'ja' } })
|
||||
expect(await b.controller.translate({ text: 'original', targetLanguage: 'ja' }, signal)).toBe('translated')
|
||||
expect(b.resolve).toHaveBeenCalledWith({ text: 'original', provider: 'google', targetLanguage: 'ja' })
|
||||
expect(() => b.controller.limits(AbortSignal.abort(new Error('cancelled query')))).toThrow('cancelled query')
|
||||
})
|
||||
@@ -0,0 +1,350 @@
|
||||
/** The optional namespace and both UI contributions leave with the plugin. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { InjectParams, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { RemoteError, type RemoteResult, type TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { CotTranslationPreferences, CotTranslationSnapshot } from '../src/preferences.ts'
|
||||
import type { TranslationBodyInjected } from '../src/client/TranslationBody.tsx'
|
||||
import type { TranslationFormInjected } from '../src/client/preferences-form.ts'
|
||||
import { inject, mountTranslation } from '../src/client/mount.ts'
|
||||
|
||||
const contribution: TypertRemoteContribution = { package: '@deepseek-ai/dsh-experimental-client-ui-cot-translation', descriptors: [] }
|
||||
const sourceSessionId = SessionId('reasoning-source')
|
||||
|
||||
async function fixture(failure = false, preferences: CotTranslationPreferences = { provider: 'bing', targetLanguage: 'auto' }) {
|
||||
const ctx = new Context(), unmount = vi.fn(async () => {})
|
||||
const listeners = new Set<() => void>()
|
||||
let catalog: CotTranslationSnapshot = { maxTextChars: 12, preferences }
|
||||
class TestRemote extends Service {
|
||||
constructor() { super(ctx, 'remote') }
|
||||
$on(event: string, listener: () => void) {
|
||||
expect(event).toBe('settings/document-updated')
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
async $mount(value: TypertRemoteContribution) { expect(value).toBe(contribution); return unmount }
|
||||
}
|
||||
new TestRemote()
|
||||
const limits = vi.fn<(signal: AbortSignal) => Promise<RemoteResult<CotTranslationSnapshot>>>(async () => failure ? { ok: false, error: new RemoteError('cotTranslation/failed', 'limits unavailable', {}) }
|
||||
: { ok: true, value: catalog })
|
||||
const translate = vi.fn(async () => ({ ok: true, value: 'translated' }))
|
||||
ctx.provide('remote.cotTranslation', { limits, translate } as never)
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
const scope = stubConfigForm<CotTranslationPreferences>()
|
||||
ctx.provide('configForms', { get: (name: string) => { expect(name).toBe('cot-translation'); return scope.scope } } as never)
|
||||
await ctx.plugin(SlotRegistry)
|
||||
ctx.slots.register({ name: 'root', children: {
|
||||
'conversation.chat.reasoning-body': { kind: 'chain', scope: 'session' },
|
||||
'plugins.bundle.config': { kind: 'keyed', scope: 'root' },
|
||||
} } as never, () => null)
|
||||
return { ctx, limits, translate, unmount, scope, listeners,
|
||||
setCatalog: (value: CotTranslationSnapshot) => { catalog = value },
|
||||
invalidate: () => { for (const listener of [...listeners]) listener() } }
|
||||
}
|
||||
|
||||
function bodyFace(value: Record<string, unknown>): asserts value is Record<string, unknown> & TranslationBodyInjected {
|
||||
assert(typeof value.translate === 'function')
|
||||
assert(typeof value.hooks === 'object' && value.hooks !== null)
|
||||
}
|
||||
|
||||
function formFace(value: Record<string, unknown>): asserts value is Record<string, unknown> & Pick<TranslationFormInjected, 'edit'> {
|
||||
assert(typeof value.edit === 'function')
|
||||
}
|
||||
|
||||
/** Invoke the owned reasoning contribution with its declared Session parameters. */
|
||||
function injectReasoning(entry: StoredEntry, sessionId: SessionId): Record<string, unknown> {
|
||||
assert(typeof entry.inject === 'function')
|
||||
const inject = entry.inject as (...args: InjectParams<'conversation.chat.reasoning-body', undefined>) => Record<string, unknown>
|
||||
return inject(sessionId)
|
||||
}
|
||||
|
||||
it.each(['bing', 'google'] as const)('binds %s requests to each reasoning Session and overrides a supplied source id', async (provider) => {
|
||||
const b = await fixture(false, { provider, targetLanguage: 'ja' })
|
||||
try {
|
||||
const dispose = await mountTranslation(b.ctx, contribution)
|
||||
const entry = b.ctx.slots.entries('conversation.chat.reasoning-body')[0]!
|
||||
const first = injectReasoning(entry, sourceSessionId), otherSessionId = SessionId('other-reasoning-source')
|
||||
const second = injectReasoning(entry, otherSessionId)
|
||||
bodyFace(first); bodyFace(second)
|
||||
const request = { text: 'original', provider, targetLanguage: 'ja', sessionId: SessionId('forged-source') }
|
||||
const signal = new AbortController().signal
|
||||
expect(await first.translate(request, signal)).toBe('translated')
|
||||
expect(await second.translate(request, signal)).toBe('translated')
|
||||
expect(b.translate.mock.calls).toEqual([
|
||||
[{ ...request, sessionId: sourceSessionId }, signal],
|
||||
[{ ...request, sessionId: otherSessionId }, signal],
|
||||
])
|
||||
await dispose()
|
||||
} finally { await b.ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('registers translated reasoning and preferences, adopts accepted choices, and disposes both before the Remote', async () => {
|
||||
const b = await fixture()
|
||||
try {
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply: ctx => mountTranslation(ctx, contribution) })
|
||||
await fiber
|
||||
const entry = b.ctx.slots.entries('conversation.chat.reasoning-body')[0]!
|
||||
expect(entry.locale).toBe('cotTranslation')
|
||||
expect(entry.select?.({ text: 'original', running: false } as never)).toEqual({ text: 'original', running: false })
|
||||
const body = injectReasoning(entry, sourceSessionId)
|
||||
bodyFace(body)
|
||||
expect(body.hooks.translationLimit.getSnapshot()).toBe(12)
|
||||
expect(body.hooks.preferences.getSnapshot()).toEqual({ provider: 'bing', targetLanguage: 'auto' })
|
||||
b.setCatalog({ maxTextChars: 12, preferences: { provider: 'bing', targetLanguage: 'ja' } })
|
||||
b.scope.publish({ value: { provider: 'bing', targetLanguage: 'ja' } })
|
||||
await vi.waitFor(() => { expect(body.hooks.preferences.getSnapshot()).toEqual({ provider: 'bing', targetLanguage: 'ja' }) })
|
||||
expect(b.ctx.slots.entries('plugins.bundle.config')[0]?.options).toMatchObject({ key: '@deepseek-ai/dsh-experimental-cot-translation-bundle' })
|
||||
expect(b.ctx.slots.entries('plugins.bundle.config')[0]!.inject!()).toHaveProperty('hooks.translationForm')
|
||||
const request = { text: 'original', provider: 'bing' as const, targetLanguage: 'ja' }, signal = new AbortController().signal
|
||||
expect(await body.translate(request, signal)).toBe('translated')
|
||||
b.translate.mockResolvedValueOnce({ ok: false, error: new RemoteError('cotTranslation/failed', 'request failed', {}) } as never)
|
||||
await expect(body.translate(request, signal)).rejects.toThrow('request failed')
|
||||
await fiber.dispose()
|
||||
expect(b.ctx.slots.entries('conversation.chat.reasoning-body')).toHaveLength(0)
|
||||
expect(b.ctx.slots.entries('plugins.bundle.config')).toHaveLength(0)
|
||||
expect(b.scope.listenerCount()).toBe(0)
|
||||
expect(b.listeners.size).toBe(0)
|
||||
expect(b.unmount).toHaveBeenCalledOnce()
|
||||
} finally { await b.ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it.each(['limits', 'registration'])('rolls back the Remote when %s fails', async (kind) => {
|
||||
const b = await fixture(kind === 'limits')
|
||||
if (kind === 'registration') vi.spyOn(b.ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('registration failed') })
|
||||
try {
|
||||
await expect(mountTranslation(b.ctx, contribution)).rejects.toThrow()
|
||||
expect(b.unmount).toHaveBeenCalledOnce()
|
||||
expect(b.scope.listenerCount()).toBe(0)
|
||||
} finally { await b.ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('loads the browser entry through its generated optional Remote contribution', async () => {
|
||||
const b = await fixture()
|
||||
vi.doMock('@deepseek-ai/dsh-experimental-client-ui-cot-translation/remote', () => ({ default: contribution }))
|
||||
try {
|
||||
const browser = await import('../src/client/index.ts')
|
||||
expect(browser.inject).toEqual(inject)
|
||||
const fiber = b.ctx.plugin({ inject: [...browser.inject], apply: browser.apply })
|
||||
await fiber
|
||||
expect(b.ctx.slots.entries('conversation.chat.reasoning-body')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(b.unmount).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await b.ctx.fiber.dispose()
|
||||
vi.doUnmock('@deepseek-ai/dsh-experimental-client-ui-cot-translation/remote')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
it.each(['loading', 'memory'] as const)('uses Host Google preferences while the settings form is %s', async (mode) => {
|
||||
const preferences: CotTranslationPreferences = { provider: 'google', targetLanguage: 'ja' }
|
||||
const b = await fixture(false, preferences)
|
||||
if (mode === 'memory') b.scope.publish({ status: 'unavailable', mode: 'memory', value: undefined })
|
||||
try {
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply: ctx => mountTranslation(ctx, contribution) })
|
||||
await fiber
|
||||
const body = injectReasoning(b.ctx.slots.entries('conversation.chat.reasoning-body')[0]!, sourceSessionId)
|
||||
bodyFace(body)
|
||||
expect(body.hooks.preferences.getSnapshot()).toEqual(preferences)
|
||||
expect(b.scope.scope.getSnapshot().value).toBeUndefined()
|
||||
await body.translate({ text: 'original', ...body.hooks.preferences.getSnapshot() }, new AbortController().signal)
|
||||
expect(b.translate).toHaveBeenCalledWith({ text: 'original', provider: 'google', targetLanguage: 'ja', sessionId: sourceSessionId }, expect.any(AbortSignal))
|
||||
} finally { await b.ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('does not install a reasoning renderer before authoritative metadata arrives, and invalidates stale initial reads', async () => {
|
||||
const b = await fixture(), pending = Promise.withResolvers<RemoteResult<CotTranslationSnapshot>>()
|
||||
b.limits.mockImplementationOnce(() => pending.promise)
|
||||
try {
|
||||
const task = mountTranslation(b.ctx, contribution)
|
||||
await vi.waitFor(() => { expect(b.limits).toHaveBeenCalledOnce() })
|
||||
expect(b.ctx.slots.entries('conversation.chat.reasoning-body')).toHaveLength(0)
|
||||
expect(b.translate).not.toHaveBeenCalled()
|
||||
b.setCatalog({ maxTextChars: 4, preferences: { provider: 'google', targetLanguage: 'ja' } })
|
||||
b.invalidate()
|
||||
pending.resolve({ ok: true, value: { maxTextChars: 12, preferences: { provider: 'bing', targetLanguage: 'auto' } } })
|
||||
const dispose = await task
|
||||
const body = injectReasoning(b.ctx.slots.entries('conversation.chat.reasoning-body')[0]!, sourceSessionId)
|
||||
bodyFace(body)
|
||||
expect(body.hooks.preferences.getSnapshot()).toEqual({ provider: 'google', targetLanguage: 'ja' })
|
||||
expect(body.hooks.translationLimit.getSnapshot()).toBe(4)
|
||||
expect(b.limits).toHaveBeenCalledTimes(2)
|
||||
await dispose()
|
||||
} finally { await b.ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('refreshes accepted Host preferences and limits on invalidation and reconnect without adopting drafts', async () => {
|
||||
const b = await fixture(false, { provider: 'google', targetLanguage: 'ja' })
|
||||
try {
|
||||
const dispose = await mountTranslation(b.ctx, contribution)
|
||||
const body = injectReasoning(b.ctx.slots.entries('conversation.chat.reasoning-body')[0]!, sourceSessionId)
|
||||
bodyFace(body)
|
||||
const form = b.ctx.slots.entries('plugins.bundle.config')[0]!.inject!()
|
||||
formFace(form)
|
||||
form.edit('provider', 'bing')
|
||||
expect(body.hooks.preferences.getSnapshot()).toEqual({ provider: 'google', targetLanguage: 'ja' })
|
||||
b.setCatalog({ maxTextChars: 4, preferences: { provider: 'google', targetLanguage: 'ja' } })
|
||||
b.invalidate()
|
||||
await vi.waitFor(() => { expect(body.hooks.translationLimit.getSnapshot()).toBe(4) })
|
||||
b.setCatalog({ maxTextChars: 8, preferences: { provider: 'bing', targetLanguage: 'zh' } })
|
||||
b.ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(body.hooks.preferences.getSnapshot()).toEqual({ provider: 'bing', targetLanguage: 'zh' }) })
|
||||
expect(body.hooks.translationLimit.getSnapshot()).toBe(8)
|
||||
await dispose()
|
||||
} finally { await b.ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it.each(['resolve', 'reject'] as const)('cancels setup, joins the pending read, and excludes a late %s after withdrawal', async (outcome) => {
|
||||
const b = await fixture(), pending = Promise.withResolvers<RemoteResult<CotTranslationSnapshot>>()
|
||||
b.limits.mockImplementationOnce(() => pending.promise)
|
||||
try {
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply: ctx => mountTranslation(ctx, contribution) })
|
||||
await vi.waitFor(() => { expect(b.limits).toHaveBeenCalledOnce() })
|
||||
const querySignal = b.limits.mock.calls[0]![0]
|
||||
const disposing = fiber.dispose()
|
||||
await vi.waitFor(() => { expect(querySignal.aborted).toBe(true) })
|
||||
expect(b.unmount).not.toHaveBeenCalled()
|
||||
if (outcome === 'resolve') pending.resolve({ ok: true,
|
||||
value: { maxTextChars: 12, preferences: { provider: 'google', targetLanguage: 'ja' } } })
|
||||
else pending.reject(new Error('late metadata failure'))
|
||||
await disposing
|
||||
expect(b.ctx.slots.entries('conversation.chat.reasoning-body')).toHaveLength(0)
|
||||
expect(b.scope.listenerCount()).toBe(0)
|
||||
expect(b.listeners.size).toBe(0)
|
||||
expect(b.unmount).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
pending.resolve({ ok: true, value: { maxTextChars: 12, preferences: { provider: 'bing', targetLanguage: 'auto' } } })
|
||||
await b.ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
it('holds new fragments during metadata refresh and rejects stale routing or limits before submission', async () => {
|
||||
const b = await fixture(), pending = Promise.withResolvers<RemoteResult<CotTranslationSnapshot>>()
|
||||
try {
|
||||
const dispose = await mountTranslation(b.ctx, contribution)
|
||||
const body = injectReasoning(b.ctx.slots.entries('conversation.chat.reasoning-body')[0]!, sourceSessionId)
|
||||
bodyFace(body)
|
||||
b.limits.mockImplementationOnce(() => pending.promise)
|
||||
b.invalidate()
|
||||
const stale = body.translate({ text: 'private', provider: 'bing', targetLanguage: 'zh' }, new AbortController().signal)
|
||||
expect(b.translate).not.toHaveBeenCalled()
|
||||
pending.resolve({ ok: true, value: { maxTextChars: 4, preferences: { provider: 'google', targetLanguage: 'ja' } } })
|
||||
await expect(stale).rejects.toThrow('changed')
|
||||
await expect(body.translate({ text: 'private', provider: 'google', targetLanguage: 'ja' }, new AbortController().signal))
|
||||
.rejects.toThrow('changed')
|
||||
await expect(body.translate({ text: 'word', provider: 'google', targetLanguage: 'zh' }, new AbortController().signal))
|
||||
.rejects.toThrow('changed')
|
||||
expect(b.translate).not.toHaveBeenCalled()
|
||||
expect(await body.translate({ text: 'word', provider: 'google', targetLanguage: 'ja' }, new AbortController().signal)).toBe('translated')
|
||||
await dispose()
|
||||
} finally {
|
||||
pending.resolve({ ok: true, value: { maxTextChars: 12, preferences: { provider: 'bing', targetLanguage: 'auto' } } })
|
||||
await b.ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rechecks the caller abort after a held metadata query completes', async () => {
|
||||
const b = await fixture(), pending = Promise.withResolvers<RemoteResult<CotTranslationSnapshot>>()
|
||||
try {
|
||||
const dispose = await mountTranslation(b.ctx, contribution)
|
||||
const body = injectReasoning(b.ctx.slots.entries('conversation.chat.reasoning-body')[0]!, sourceSessionId)
|
||||
bodyFace(body)
|
||||
b.limits.mockImplementationOnce(() => pending.promise)
|
||||
b.invalidate()
|
||||
const caller = new AbortController(), reason = new Error('cancelled before dispatch')
|
||||
const translated = body.translate({ text: 'private', provider: 'bing', targetLanguage: 'zh' }, caller.signal)
|
||||
caller.abort(reason)
|
||||
pending.resolve({ ok: true, value: { maxTextChars: 12, preferences: { provider: 'bing', targetLanguage: 'auto' } } })
|
||||
await expect(translated).rejects.toBe(reason)
|
||||
expect(b.translate).not.toHaveBeenCalled()
|
||||
await dispose()
|
||||
} finally {
|
||||
pending.resolve({ ok: true, value: { maxTextChars: 12, preferences: { provider: 'bing', targetLanguage: 'auto' } } })
|
||||
await b.ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
it('rolls back a failed initial metadata transport read', async () => {
|
||||
const b = await fixture()
|
||||
b.limits.mockRejectedValueOnce(new Error('metadata transport failed'))
|
||||
try {
|
||||
await expect(mountTranslation(b.ctx, contribution)).rejects.toThrow('metadata transport failed')
|
||||
expect(b.ctx.slots.entries('conversation.chat.reasoning-body')).toHaveLength(0)
|
||||
expect(b.unmount).toHaveBeenCalledOnce()
|
||||
} finally { await b.ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it.each(['throw', 'refuse'] as const)('does not submit after a %s metadata refresh and retries metadata before the next fragment', async (outcome) => {
|
||||
const b = await fixture()
|
||||
try {
|
||||
const dispose = await mountTranslation(b.ctx, contribution)
|
||||
const body = injectReasoning(b.ctx.slots.entries('conversation.chat.reasoning-body')[0]!, sourceSessionId)
|
||||
bodyFace(body)
|
||||
if (outcome === 'throw') b.limits.mockRejectedValueOnce(new Error('metadata unavailable'))
|
||||
else b.limits.mockResolvedValueOnce({ ok: false, error: new RemoteError('cotTranslation/failed', 'metadata unavailable', {}) })
|
||||
b.invalidate()
|
||||
const request = { text: 'private', provider: 'bing' as const, targetLanguage: 'zh' }, signal = new AbortController().signal
|
||||
await expect(body.translate(request, signal)).rejects.toThrow('changed')
|
||||
expect(b.translate).not.toHaveBeenCalled()
|
||||
expect(await body.translate(request, signal)).toBe('translated')
|
||||
await dispose()
|
||||
} finally { await b.ctx.fiber.dispose() }
|
||||
})
|
||||
|
||||
it.each(['resolve', 'reject'] as const)('joins an in-flight refresh and excludes a late %s during disposal', async (outcome) => {
|
||||
const b = await fixture(), pending = Promise.withResolvers<RemoteResult<CotTranslationSnapshot>>()
|
||||
try {
|
||||
const dispose = await mountTranslation(b.ctx, contribution)
|
||||
const body = injectReasoning(b.ctx.slots.entries('conversation.chat.reasoning-body')[0]!, sourceSessionId)
|
||||
bodyFace(body)
|
||||
b.limits.mockImplementationOnce(() => pending.promise)
|
||||
b.invalidate()
|
||||
const signal = b.limits.mock.calls[1]![0]
|
||||
const disposing = dispose()
|
||||
await vi.waitFor(() => { expect(signal.aborted).toBe(true) })
|
||||
expect(b.unmount).not.toHaveBeenCalled()
|
||||
if (outcome === 'resolve') pending.resolve({ ok: true,
|
||||
value: { maxTextChars: 4, preferences: { provider: 'google', targetLanguage: 'ja' } } })
|
||||
else pending.reject(new Error('late refresh failure'))
|
||||
await disposing
|
||||
expect(body.hooks.preferences.getSnapshot()).toEqual({ provider: 'bing', targetLanguage: 'auto' })
|
||||
expect(body.hooks.translationLimit.getSnapshot()).toBe(12)
|
||||
await expect(body.translate({ text: 'private', provider: 'bing', targetLanguage: 'zh' }, new AbortController().signal))
|
||||
.rejects.toThrow('changed')
|
||||
expect(b.unmount).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
pending.resolve({ ok: true, value: { maxTextChars: 12, preferences: { provider: 'bing', targetLanguage: 'auto' } } })
|
||||
await b.ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
it('retains an invalidation raised synchronously while a refreshed snapshot is being published', async () => {
|
||||
const b = await fixture()
|
||||
let unsubscribe: (() => void) | undefined
|
||||
try {
|
||||
const dispose = await mountTranslation(b.ctx, contribution)
|
||||
const body = injectReasoning(b.ctx.slots.entries('conversation.chat.reasoning-body')[0]!, sourceSessionId)
|
||||
bodyFace(body)
|
||||
let invalidated = false
|
||||
unsubscribe = body.hooks.preferences.subscribe(() => {
|
||||
if (invalidated) return
|
||||
invalidated = true
|
||||
b.setCatalog({ maxTextChars: 4, preferences: { provider: 'bing', targetLanguage: 'ja' } })
|
||||
b.invalidate()
|
||||
})
|
||||
b.setCatalog({ maxTextChars: 8, preferences: { provider: 'google', targetLanguage: 'ja' } })
|
||||
b.invalidate()
|
||||
await vi.waitFor(() => { expect(body.hooks.preferences.getSnapshot()).toEqual({ provider: 'bing', targetLanguage: 'ja' }) })
|
||||
expect(body.hooks.translationLimit.getSnapshot()).toBe(4)
|
||||
expect(b.limits).toHaveBeenCalledTimes(3)
|
||||
await dispose()
|
||||
} finally { unsubscribe?.(); await b.ctx.fiber.dispose() }
|
||||
})
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Preferences remain staged until one explicit Settings save. */
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector, makeTranslate, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { TranslationPreferencesForm } from '../src/client/preferences-form.ts'
|
||||
import { TranslationSettings, type TranslationSettingsProps } from '../src/client/TranslationSettings.tsx'
|
||||
import type { CotTranslationPreferences } from '../src/preferences.ts'
|
||||
import { en, zh, formLabels } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
it('stages provider and language together, rejects malformed codes, and resets the language', async () => {
|
||||
const scope = stubConfigForm<CotTranslationPreferences>()
|
||||
scope.publish({ status: 'ready', value: { provider: 'bing', targetLanguage: 'auto' },
|
||||
base: { provider: 'bing', targetLanguage: 'auto' }, user: {}, writable: true, revision: 7 })
|
||||
const form = new TranslationPreferencesForm(scope.scope), { hooks, ...actions } = form.inject()
|
||||
const props = { view: 'page' as const, ...actions, useTranslationForm: bindSnapshotSelector(hooks.translationForm),
|
||||
t: ((key: keyof typeof en) => en[key]) } as TranslationSettingsProps
|
||||
const view = render(<TranslationSettings {...props} />)
|
||||
expect(view.getByLabelText(en.provider)).toHaveProperty('value', 'bing')
|
||||
expect(view.getByLabelText(en.targetLanguage)).toHaveProperty('value', 'auto')
|
||||
expect(view.getByText(en.privacy)).toBeTruthy()
|
||||
fireEvent.change(view.getByLabelText(en.provider), { target: { value: 'google' } })
|
||||
fireEvent.change(view.getByLabelText(en.targetLanguage), { target: { value: 'invalid code!' } })
|
||||
expect(view.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
|
||||
expect(scope.mutate).not.toHaveBeenCalled()
|
||||
fireEvent.change(view.getByLabelText(en.targetLanguage), { target: { value: 'zh-Hant' } })
|
||||
fireEvent.click(view.getByRole('button', { name: en.save }))
|
||||
await vi.waitFor(() => { expect(scope.mutate).toHaveBeenCalledWith([
|
||||
{ op: 'set', path: ['provider'], value: 'google' }, { op: 'set', path: ['targetLanguage'], value: 'zh-Hant' },
|
||||
], 7) })
|
||||
fireEvent.change(view.getByLabelText(en.targetLanguage), { target: { value: 'ja' } })
|
||||
fireEvent.click(view.getAllByRole('button', { name: en.reset }).at(-1)!)
|
||||
expect(view.getByLabelText(en.targetLanguage)).toHaveProperty('value', 'auto')
|
||||
view.unmount()
|
||||
form.dispose()
|
||||
expect(scope.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the shared unavailable and read-only states and supplies both dictionaries', () => {
|
||||
const scope = stubConfigForm<CotTranslationPreferences>()
|
||||
const form = new TranslationPreferencesForm(scope.scope), { hooks, ...actions } = form.inject()
|
||||
try {
|
||||
expect(Object.keys(en)).toEqual(Object.keys(zh))
|
||||
expect(formLabels(makeTranslate(zh)).save).toBe('保存')
|
||||
const props = { view: 'page' as const, ...actions, useTranslationForm: bindSnapshotSelector(hooks.translationForm),
|
||||
t: ((key: keyof typeof en) => en[key]) } as TranslationSettingsProps
|
||||
const view = render(<TranslationSettings {...props} />)
|
||||
expect(view.getByText(en.unavailable)).toBeTruthy()
|
||||
scope.publish({ status: 'ready', value: { provider: 'bing', targetLanguage: 'auto' } })
|
||||
view.rerender(<TranslationSettings {...props} />)
|
||||
expect(view.getByText(en.readOnly)).toBeTruthy()
|
||||
expect(view.getByLabelText(en.provider)).toHaveProperty('disabled', true)
|
||||
} finally { form.dispose() }
|
||||
})
|
||||
|
||||
|
||||
it('reports provider overrides, rejects an invalid draft, and resets to the inherited choice', async () => {
|
||||
const scope = stubConfigForm<CotTranslationPreferences>()
|
||||
scope.publish({ status: 'ready', value: { provider: 'google', targetLanguage: 'auto' },
|
||||
base: { provider: 'bing', targetLanguage: 'auto' }, user: { provider: 'google' }, writable: true, revision: 3 })
|
||||
const form = new TranslationPreferencesForm(scope.scope), { hooks, ...actions } = form.inject()
|
||||
const props = { view: 'page' as const, ...actions, useTranslationForm: bindSnapshotSelector(hooks.translationForm),
|
||||
t: ((key: keyof typeof en) => en[key]) } as TranslationSettingsProps
|
||||
const view = render(<TranslationSettings {...props} />)
|
||||
try {
|
||||
expect(view.getByText(en.overridden)).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: en.reset })).toBeTruthy()
|
||||
actions.edit('provider', 'bing')
|
||||
expect(hooks.translationForm.getSnapshot().invalid).toBe(false)
|
||||
actions.edit('provider', 'unsupported')
|
||||
view.rerender(<TranslationSettings {...props} />)
|
||||
expect(view.getByText(en.invalidProvider)).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
|
||||
actions.discard()
|
||||
view.rerender(<TranslationSettings {...props} />)
|
||||
fireEvent.click(view.getByRole('button', { name: en.reset }))
|
||||
expect(view.getByLabelText(en.provider)).toHaveProperty('value', 'bing')
|
||||
fireEvent.click(view.getByRole('button', { name: en.save }))
|
||||
await vi.waitFor(() => { expect(scope.mutate).toHaveBeenCalledWith([{ op: 'unset', path: ['provider'] }], 3) })
|
||||
actions.edit('provider', '')
|
||||
expect(hooks.translationForm.getSnapshot().invalid).toBe(false)
|
||||
} finally { view.unmount(); form.dispose() }
|
||||
})
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Expanded reasoning keeps its original accessible across translation lifetimes. */
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TranslationBody, type TranslationBodyProps } from '../src/client/TranslationBody.tsx'
|
||||
import type { CotTranslationPreferences } from '../src/preferences.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import type { TranslateText } from '../src/client/translation.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function fixture() {
|
||||
const preferences = createSnapshotStore<CotTranslationPreferences>({ provider: 'google', targetLanguage: 'auto' })
|
||||
const locale = createSnapshotStore<LocaleSnapshot>({ active: 'zh', locales: [], revision: 0 })
|
||||
const translationLimit = createSnapshotStore(4000)
|
||||
const translate = vi.fn<TranslateText>(async request => `${request.provider}:${request.targetLanguage}:${request.text}`)
|
||||
const props = { text: 'Original paragraph', running: false, translate, t: makeTranslate(en),
|
||||
usePreferences: bindSnapshotSelector(preferences), useTranslationLocale: bindSnapshotSelector(locale),
|
||||
useTranslationLimit: bindSnapshotSelector(translationLimit) } as TranslationBodyProps
|
||||
return { props, preferences, locale, translationLimit, translate }
|
||||
}
|
||||
|
||||
it('honors an explicit Google selection and the UI language, and switches to the original without another request', async () => {
|
||||
const b = fixture(), view = render(<TranslationBody {...b.props} />)
|
||||
await view.findByText('google:zh:Original paragraph')
|
||||
expect(b.translate).toHaveBeenCalledWith({ text: 'Original paragraph', provider: 'google', targetLanguage: 'zh' }, expect.any(AbortSignal))
|
||||
fireEvent.click(view.getByRole('button', { name: 'Original' }))
|
||||
expect(view.getByText('Original paragraph')).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: 'Translation' }))
|
||||
expect(view.getByText('google:zh:Original paragraph')).toBeTruthy()
|
||||
expect(b.translate).toHaveBeenCalledTimes(1)
|
||||
expect(view.container.querySelector('[data-translation-view]')?.getAttribute('data-translation-view')).toBe('translated')
|
||||
})
|
||||
|
||||
it('cancels stale provider or locale requests and honors an explicit language after locale changes', async () => {
|
||||
const b = fixture(), first = Promise.withResolvers<string>()
|
||||
b.translate.mockImplementationOnce(() => first.promise)
|
||||
const view = render(<TranslationBody {...b.props} />)
|
||||
const firstSignal = b.translate.mock.calls[0]![1]
|
||||
act(() => { b.preferences.set({ provider: 'bing', targetLanguage: 'ja' }) })
|
||||
expect(firstSignal.aborted).toBe(true)
|
||||
await view.findByText('bing:ja:Original paragraph')
|
||||
first.resolve('outdated translation')
|
||||
await act(async () => { await first.promise })
|
||||
expect(view.queryByText('outdated translation')).toBeNull()
|
||||
act(() => { b.locale.set({ active: 'en', locales: [], revision: 1 }) })
|
||||
expect(b.translate).toHaveBeenCalledTimes(2)
|
||||
act(() => { b.preferences.set({ provider: 'bing', targetLanguage: 'auto' }) })
|
||||
await view.findByText('bing:en:Original paragraph')
|
||||
})
|
||||
|
||||
it('shows a generic failure with original text and retries only on the reader action', async () => {
|
||||
const b = fixture()
|
||||
b.translate.mockRejectedValueOnce(new Error('secret request text'))
|
||||
const view = render(<TranslationBody {...b.props} />)
|
||||
await view.findByText(en.failed)
|
||||
expect(view.getByText('Original paragraph')).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: 'Original' }).getAttribute('aria-pressed')).toBe('true')
|
||||
expect(view.getByRole('button', { name: 'Translation' }).getAttribute('aria-pressed')).toBe('false')
|
||||
expect(view.getByRole('button', { name: 'Translation' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(view.queryByText('secret request text')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: 'Retry' }))
|
||||
await view.findByText('google:zh:Original paragraph')
|
||||
expect(view.queryByText(en.failed)).toBeNull()
|
||||
expect(view.getByRole('button', { name: 'Translation' }).hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the full original visible after a later paragraph fails and reuses completed translations on retry', async () => {
|
||||
const b = fixture()
|
||||
b.translate.mockResolvedValueOnce('第一段').mockRejectedValueOnce(new Error('provider unavailable'))
|
||||
const view = render(<TranslationBody {...b.props} text={'First\n\nSecond'} />)
|
||||
await view.findByText(en.failed)
|
||||
expect(view.getByText('First')).toBeTruthy()
|
||||
expect(view.getByText('Second')).toBeTruthy()
|
||||
expect(view.queryByText('第一段')).toBeNull()
|
||||
const toggle = view.getByRole('button', { name: 'Translation' })
|
||||
expect(toggle.hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(toggle)
|
||||
expect(view.container.querySelector('[data-translation-view]')?.getAttribute('data-translation-view')).toBe('original')
|
||||
expect(b.translate).toHaveBeenCalledTimes(2)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Retry' }))
|
||||
await view.findByText('第一段')
|
||||
await view.findByText('google:zh:Second')
|
||||
expect(toggle.hasAttribute('disabled')).toBe(false)
|
||||
expect(b.translate).toHaveBeenCalledTimes(3)
|
||||
expect(b.translate.mock.calls[2]?.[0].text).toBe('Second')
|
||||
})
|
||||
|
||||
it('keeps an unfinished streaming tail original and cancels the disclosure on unmount', async () => {
|
||||
const b = fixture(), pending = Promise.withResolvers<string>()
|
||||
b.translate.mockImplementationOnce(() => pending.promise)
|
||||
const view = render(<TranslationBody {...b.props} text="Unfinished" running />)
|
||||
expect(b.translate).not.toHaveBeenCalled()
|
||||
view.rerender(<TranslationBody {...b.props} text={'First\n\nTail'} running />)
|
||||
expect(view.getByRole('status', { name: en.translating })).toBeTruthy()
|
||||
const signal = b.translate.mock.calls[0]![1]
|
||||
view.unmount()
|
||||
expect(signal.aborted).toBe(true)
|
||||
pending.resolve('ignored')
|
||||
await pending.promise
|
||||
})
|
||||
|
||||
it('cancels the previous request and rechunks when the request limit changes', async () => {
|
||||
const b = fixture(), first = Promise.withResolvers<string>()
|
||||
b.translate.mockImplementationOnce(() => first.promise)
|
||||
const view = render(<TranslationBody {...b.props} text="abcdefghij" />)
|
||||
const firstSignal = b.translate.mock.calls[0]![1]
|
||||
act(() => { b.translationLimit.set(4) })
|
||||
expect(firstSignal.aborted).toBe(true)
|
||||
await view.findByText('google:zh:abcdgoogle:zh:efghgoogle:zh:ij')
|
||||
expect(b.translate.mock.calls.slice(1).map(([request]) => request.text)).toEqual(['abcd', 'efgh', 'ij'])
|
||||
first.resolve('outdated long request')
|
||||
await act(async () => { await first.promise })
|
||||
expect(view.queryByText('outdated long request')).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
/** Paragraph completion, provider failures, and disclosure-owned cancellation. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ReasoningTranslation, reasoningFragments, type TranslateText, type TranslationState } from '../src/client/translation.ts'
|
||||
|
||||
function fixture(maxTextChars = 4000) {
|
||||
const states: TranslationState[] = []
|
||||
const translate = vi.fn<TranslateText>(async ({ text }) => `译:${text}`)
|
||||
const controller = new ReasoningTranslation({ provider: 'google', targetLanguage: 'zh' }, maxTextChars, translate,
|
||||
(state) => { states.push(state) })
|
||||
return { controller, translate, states }
|
||||
}
|
||||
|
||||
describe('reasoning translation', () => {
|
||||
it.each([
|
||||
['', true, []],
|
||||
['unfinished', true, []],
|
||||
['settled', false, ['settled']],
|
||||
['First\n\nTail', true, ['First']],
|
||||
['First\r\n \r\nSecond', false, ['First', 'Second']],
|
||||
[' \n\n', false, []],
|
||||
])('translates only completed content in %j', async (text, running, expected) => {
|
||||
const b = fixture()
|
||||
try {
|
||||
b.controller.update(text, running)
|
||||
await vi.waitFor(() => { expect(b.states.at(-1)?.pending).toBe(false) })
|
||||
expect(b.translate.mock.calls.map(([request]) => request.text)).toEqual(expected)
|
||||
} finally { b.controller.dispose() }
|
||||
})
|
||||
|
||||
it('retains exact whitespace and keeps every chunk within the configured Unicode-safe cap', () => {
|
||||
for (const text of ['abcdefgh', 'hello world again', 'A😀B😀C😀', 'AB😀CD', 'first\r\n \r\nsecond', ' leading trailing ']) {
|
||||
const parts = reasoningFragments(text, false, 3)
|
||||
expect(parts.map(part => part.text + part.suffix).join('')).toBe(text)
|
||||
for (const part of parts) {
|
||||
expect(part.text.length).toBeLessThanOrEqual(3)
|
||||
expect(part.text.endsWith('\ud83d')).toBe(false)
|
||||
expect(part.text.startsWith('\ude00')).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('finishes long streaming chunks while retaining the incomplete tail', async () => {
|
||||
const b = fixture(4)
|
||||
try {
|
||||
b.controller.update('abcdefghij', true)
|
||||
await vi.waitFor(() => { expect(b.states.at(-1)).toEqual({ text: '译:abcd译:efghij', pending: false, failed: false }) })
|
||||
expect(b.translate.mock.calls.map(([request]) => request.text)).toEqual(['abcd', 'efgh'])
|
||||
b.controller.update('abcdefghij', false)
|
||||
await vi.waitFor(() => { expect(b.translate).toHaveBeenCalledTimes(3) })
|
||||
expect(b.translate.mock.calls[2]?.[0]).toMatchObject({ provider: 'google', targetLanguage: 'zh', text: 'ij' })
|
||||
} finally { b.controller.dispose() }
|
||||
})
|
||||
|
||||
it('does not restart an in-flight paragraph on streaming updates and caches repeated completed text', async () => {
|
||||
const pending = Promise.withResolvers<string>()
|
||||
const b = fixture()
|
||||
b.translate.mockImplementationOnce(() => pending.promise)
|
||||
try {
|
||||
b.controller.update('First\n\nTail', true)
|
||||
b.controller.update('First\n\nTail continues', true)
|
||||
expect(b.translate).toHaveBeenCalledTimes(1)
|
||||
pending.resolve('第一段')
|
||||
await vi.waitFor(() => { expect(b.states.at(-1)?.text).toBe('第一段\n\nTail continues') })
|
||||
b.controller.update('First\n\nFirst\n\nTail continues', true)
|
||||
expect(b.translate).toHaveBeenCalledTimes(1)
|
||||
expect(b.states.at(-1)?.text).toBe('第一段\n\n第一段\n\nTail continues')
|
||||
} finally { pending.resolve('第一段'); b.controller.dispose() }
|
||||
})
|
||||
|
||||
it('keeps a repeated unfinished tail original until the paragraph completes', async () => {
|
||||
const b = fixture()
|
||||
try {
|
||||
b.controller.update('First\n\nFirst', true)
|
||||
await vi.waitFor(() => { expect(b.states.at(-1)?.pending).toBe(false) })
|
||||
expect(b.states.at(-1)?.text).toBe('译:First\n\nFirst')
|
||||
b.controller.update('First\n\nFirst words', true)
|
||||
expect(b.states.at(-1)?.text).toBe('译:First\n\nFirst words')
|
||||
expect(b.translate.mock.calls.map(([request]) => request.text)).toEqual(['First'])
|
||||
b.controller.update('First\n\nFirst words', false)
|
||||
await vi.waitFor(() => { expect(b.states.at(-1)?.text).toBe('译:First\n\n译:First words') })
|
||||
expect(b.translate.mock.calls.map(([request]) => request.text)).toEqual(['First', 'First words'])
|
||||
} finally { b.controller.dispose() }
|
||||
})
|
||||
|
||||
it('requires an explicit retry after failure and does not publish provider messages', async () => {
|
||||
const b = fixture()
|
||||
b.translate.mockRejectedValueOnce(new Error('sensitive provider response'))
|
||||
try {
|
||||
b.controller.update('First', false)
|
||||
await vi.waitFor(() => { expect(b.states.at(-1)?.failed).toBe(true) })
|
||||
b.controller.update('First\n\nSecond', false)
|
||||
expect(b.translate).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.stringify(b.states)).not.toContain('sensitive')
|
||||
b.controller.retry()
|
||||
await vi.waitFor(() => { expect(b.states.at(-1)).toEqual({ text: '译:First\n\n译:Second', failed: false, pending: false }) })
|
||||
expect(b.translate).toHaveBeenCalledTimes(3)
|
||||
} finally { b.controller.dispose() }
|
||||
})
|
||||
|
||||
it.each(['resolve', 'reject'] as const)('aborts on disposal and ignores a late %s', async (outcome) => {
|
||||
const pending = Promise.withResolvers<string>()
|
||||
const b = fixture()
|
||||
b.translate.mockImplementationOnce(() => pending.promise)
|
||||
b.controller.update('First', false)
|
||||
const request = b.translate.mock.calls[0]!
|
||||
const count = b.states.length
|
||||
b.controller.dispose()
|
||||
expect(request[1].aborted).toBe(true)
|
||||
b.controller.update('Second', false)
|
||||
b.controller.retry()
|
||||
if (outcome === 'resolve') pending.resolve('late')
|
||||
else pending.reject(new Error('late'))
|
||||
await pending.promise.catch(() => {})
|
||||
await Promise.resolve()
|
||||
expect(b.states).toHaveLength(count)
|
||||
expect(b.translate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src/client",
|
||||
"src/preferences.ts",
|
||||
"src/css-modules.d.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/locale/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/store"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-chat/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-plugin-manager/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-renderer"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-settings/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../translator"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/index.ts",
|
||||
"src/preferences.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../api/session-controller/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../translator"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-experimental-client-ui-cot-translation', ['lib/types/index.js'], { hostPhase: true })
|
||||
@@ -0,0 +1,37 @@
|
||||
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/cot-translation-bundle/README.md
|
||||
/:
|
||||
en: 48432c317d712e6d
|
||||
zh: 74d79e7df5bc9036
|
||||
/deepseek-ai-dsh-experimental-cot-translation-bundle:
|
||||
en: 05eca6db27f8c62b
|
||||
zh: 9e5f60e80863d49f
|
||||
/deepseek-ai-dsh-experimental-cot-translation-bundle/summary:
|
||||
en: b42d79a3fe61d3b2
|
||||
zh: a65aef3e75acf942
|
||||
/deepseek-ai-dsh-experimental-cot-translation-bundle/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: ce40cab8e7534137
|
||||
/deepseek-ai-dsh-experimental-cot-translation-bundle/use-this-package:
|
||||
en: 3f32234ecb73a317
|
||||
zh: 9103885ceb99bb28
|
||||
/deepseek-ai-dsh-experimental-cot-translation-bundle/understand-the-implementation:
|
||||
en: 28e889caf2ede244
|
||||
zh: 741e2a4c2be7de68
|
||||
/deepseek-ai-dsh-experimental-cot-translation-bundle/further-exploration:
|
||||
en: ad216b6f9e0ccaf6
|
||||
zh: 8fee46ca2cfba4a6
|
||||
/deepseek-ai-dsh-experimental-cot-translation-bundle/model-experience:
|
||||
en: 9b35d12b4a44c043
|
||||
zh: 30367a3f7fb06c41
|
||||
/deepseek-ai-dsh-experimental-cot-translation-bundle/model-experience/kv-cache-effect:
|
||||
en: 618f025179e1d140
|
||||
zh: 177f5dfbaf8a1c49
|
||||
/deepseek-ai-dsh-experimental-cot-translation-bundle/known-limitations-and-deferred-work:
|
||||
en: caafc57396d85a41
|
||||
zh: 9c56f15ff1812139
|
||||
/deepseek-ai-dsh-experimental-cot-translation-bundle/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 8ec3e73528ddb1a6
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
description: "Enable machine translation of displayed reasoning from the Plugins page."
|
||||
kind: "package-bundle"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-cot-translation-bundle
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Further Exploration](#further-exploration)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Open Plugins and enable Reasoning translation in the Official group. Open its details to select Google or Bing and a target language. `auto` follows the interface language; an explicit language code such as `zh`, `en`, or `ja` overrides it. Save applies the preferences.
|
||||
|
||||
Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. Original switches the expanded body back to the source. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
|
||||
|
||||
The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Translation requires no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Provider failure never sends the text to another service automatically.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Maintainer details — click to expand</summary>
|
||||
|
||||
The bundle patch inserts `translator` and `cot-translation`, selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. Native Chat renders the original Markdown when no expanded-body contribution is present.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
[Text translation subsystem](../../../docs/subsystems/translation.md) · [Translator service](../translator/README.md) · [GUI consumer](../client-ui-cot-translation/README.md)
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the GUI consumer's normal Session activation; the Session's preset owns startup hooks and any context they add.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Translation records leave original reasoning intact; normal Session activation has the cache effects described by the [GUI consumer](../client-ui-cot-translation/README.md#model-experience).
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
|
||||
- Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
|
||||
|
||||
-----
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
description: "从插件页面启用已展示思考内容的机器翻译。"
|
||||
kind: "package-bundle"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-cot-translation-bundle
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
将展开的思考内容翻译为界面语言或其他指定语言。在 Web 或 Desktop 的插件页面启用思考过程翻译;发布的 profile 默认禁用该功能。默认翻译服务为 Bing,也可在 Bundle 设置中选择 Google。原始思考内容始终可访问,Session 与模型输入中的原文保持不变。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用本包](#use-this-package)
|
||||
- [了解实现](#understand-the-implementation)
|
||||
- [延伸阅读](#further-exploration)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与后续工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
打开插件页面,在官方分组中启用思考过程翻译。打开详情,选择 Google 或 Bing 及目标语言。`auto` 跟随界面语言;填写 `zh`、`en` 或 `ja` 等语言代码则使用指定语言。保存后应用设置。
|
||||
|
||||
展开思考行以开始翻译。内容区翻译完整段落和流式长段落中达到请求长度的前缀,并保留未完成的末尾片段。原文按钮将展开的内容切回原始文本。翻译失败时保留原文,并提供重试。禁用 Bundle 会移除控件并取消待处理请求。成功译文仍保存在 Session 中,相同请求在重新展开、页面重新加载或 Host 重启后会复用已有结果。
|
||||
|
||||
所选服务会接收展开的思考内容,其中可能包含私有代码与对话细节。Google 和 Bing 通过 POST 请求体接收原文。翻译无需额外登录或 API key,但两种免登录端点均不保证可用性或免费额度。Provider 失败后不会自动将文本发送给其他服务。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 了解实现
|
||||
|
||||
<details>
|
||||
<summary>维护者细节 — 点击展开</summary>
|
||||
|
||||
Bundle 补丁插入 `translator` 和 `cot-translation`,选择 Bing 与界面语言。Translator 负责 Provider 协议、网络限制及统一持久保存的请求与结果;GUI 消费者负责设置、段落调度和当前展示使用的本地缓存。GUI 直接复用已保存结果,新翻译需要现有写入器时等待正常 Session 激活。生成的 Remote contribution 仅在 Bundle 启用期间挂载。没有展开内容 contribution 时,原生 Chat 渲染原始 Markdown。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 延伸阅读
|
||||
|
||||
[文本翻译子系统](../../../docs/subsystems/translation.zh.md) · [Translator 服务](../translator/README.zh.md) · [GUI 消费者](../client-ui-cot-translation/README.zh.md)
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
间接影响来自 GUI 消费者的正常 Session 激活;Session 的 preset 负责启动钩子及其添加的上下文。
|
||||
|
||||
#### KV Cache 效果
|
||||
|
||||
翻译记录保留原始思考内容;正常 Session 激活的缓存影响见 [GUI 消费者](../client-ui-cot-translation/README.zh.md#model-experience)。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 折叠摘要保留原始语言。读者展开思考内容后才开始翻译;译文可能改变 Markdown 或代码格式。原文始终可访问。
|
||||
- 免登录 Google 和 Bing 浏览器端点可能失败、限制请求或更改响应格式。中国大陆连通性取决于部署的网络与代理配置。成功译文在折叠、移除 Bundle 或重启后仍保留在 Session 中。实验性记录在未来 Session 格式迁移中仅提供尽力保留。
|
||||
|
||||
-----
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者工作上下文 — 点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,8 @@
|
||||
- insert:
|
||||
- id: translator
|
||||
name: '@deepseek-ai/dsh-experimental-translator'
|
||||
- id: cot-translation
|
||||
name: '@deepseek-ai/dsh-experimental-client-ui-cot-translation'
|
||||
config:
|
||||
provider: bing
|
||||
targetLanguage: auto
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="36" height="36" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" stroke-width="1">
|
||||
<path d="M10.2854 5.71481C12.9673 8.39663 14.1182 11.5938 12.8562 12.8559C11.5942 14.1179 8.39706 12.9669 5.71518 10.2851C3.03333 7.60323 1.88236 4.40608 3.14441 3.14403C4.40644 1.882 7.6036 3.03297 10.2854 5.71481Z" stroke="#3F77D8" />
|
||||
<path d="M10.2854 10.2851C7.6036 12.9669 4.40644 14.1179 3.14441 12.8559C1.88236 11.5938 3.03333 8.39663 5.71518 5.71481C8.39706 3.03297 11.5942 1.882 12.8562 3.14403C14.1182 4.40608 12.9673 7.60323 10.2854 10.2851Z" stroke="#3F77D8" />
|
||||
<path d="M8.86291 8.0002C8.86291 8.47549 8.47762 8.86087 8.00224 8.86087C7.52694 8.86087 7.1416 8.47549 7.1416 8.0002C7.1416 7.52485 7.52694 7.13953 8.00224 7.13953C8.47762 7.13953 8.86291 7.52485 8.86291 8.0002Z" fill="#3F77D8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 826 B |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Machine translation of reasoning",
|
||||
"description": "Machine translation of expanded reasoning; source text is sent to the selected service"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "思考过程机器翻译",
|
||||
"description": "机器翻译展开的思考内容;原文会发送给所选服务"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-experimental-cot-translation-bundle",
|
||||
"description": "Experimental machine translation of expanded reasoning",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"icon": "./icon.svg",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/experimental/cot-translation-bundle"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
},
|
||||
"files": [
|
||||
"icon.svg",
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-experimental-translator": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-client-ui-cot-translation": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:~",
|
||||
"js-yaml": "^4.2.0"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Optional reasoning translation composition; runtime rows live in cordis.patch.yml. */
|
||||
export {}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({ entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false })
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/translator/README.md
|
||||
/:
|
||||
en: a40bd4dca5f5338f
|
||||
zh: 6e0518949670c59d
|
||||
en: 58f755be95024a6c
|
||||
zh: 0996e614db7a5208
|
||||
/deepseek-ai-dsh-experimental-translator:
|
||||
en: de5ee8ccf9094431
|
||||
zh: ca8ed948d6fb6553
|
||||
@@ -15,23 +15,23 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 91dd6a6ee0635d20
|
||||
/deepseek-ai-dsh-experimental-translator/use-this-package:
|
||||
en: d84fddbca98871cf
|
||||
zh: cfac679a3848762a
|
||||
en: 2da4f2d18fc1631a
|
||||
zh: 5fe25fe66e45eeb7
|
||||
/deepseek-ai-dsh-experimental-translator/understand-the-implementation:
|
||||
en: 7658bdb9787f638e
|
||||
zh: b0deca12bf2e03d5
|
||||
en: 89d7021a51c8d3e7
|
||||
zh: 55d1fc8f12a1aa2e
|
||||
/deepseek-ai-dsh-experimental-translator/further-exploration:
|
||||
en: 17792a9fe5429633
|
||||
zh: b51e5e3c30b0e14d
|
||||
/deepseek-ai-dsh-experimental-translator/model-experience:
|
||||
en: e5107394c680a585
|
||||
zh: 756a674220213e2a
|
||||
en: 4a6e38daf5f5d503
|
||||
zh: 029644ad911587a5
|
||||
/deepseek-ai-dsh-experimental-translator/model-experience/kv-cache-effect:
|
||||
en: d14db5986844ccca
|
||||
zh: 2e67f5a50b4cc9e4
|
||||
/deepseek-ai-dsh-experimental-translator/known-limitations-and-deferred-work:
|
||||
en: f84dfde870cb510e
|
||||
zh: 695c14ff852b39a8
|
||||
en: 6c24bad77d607fbb
|
||||
zh: 8ce746aca665ed39
|
||||
/deepseek-ai-dsh-experimental-translator/known-limitations-and-deferred-work/dev-note:
|
||||
en: 64fc90c1cc6f561e
|
||||
zh: 07d80380df24550a
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Translate transient text through anonymous Bing or Google endpoints, defaulting to Bing."
|
||||
description: "Translate text through anonymous Bing or Google endpoints, with optional durable Session results."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount the package as a Cordis service. Call `resolve({ text, targetLanguage, sourceLanguage?, provider? })`, then `translate(spec, signal?)`. The result is plain translated text. An omitted source uses `auto`; an omitted provider uses configured `provider`, initially `bing`. Consumers can read `maxTextChars` to split longer text before submission.
|
||||
Mount the package as a Cordis service. Call `resolve({ text, targetLanguage, sourceLanguage?, provider?, sessionId? })`, then `translate(spec, signal?)`. The result is plain translated text. An omitted source uses `auto`; an omitted provider uses configured `provider`, initially `bing`. Consumers can read `maxTextChars` to split longer text before submission.
|
||||
|
||||
| Config | Default | Meaning |
|
||||
|---|---|---|
|
||||
@@ -38,6 +38,8 @@ Mount the package as a Cordis service. Call `resolve({ text, targetLanguage, sou
|
||||
|
||||
Endpoints accept HTTP(S) URLs without credentials or fragments. Native `fetch` uses the Host's global dispatcher and the HTTP proxy policy installed by the `dsh` launcher. No new translation library is required.
|
||||
|
||||
Supply an existing `sessionId` to retain translation requests and successful results in its Session log. A successful result is durably flushed before `translate()` returns and is reused after remounts or process restarts when the exact source text, source and target languages, provider, and translation recipe match. Saved results need only durable Session storage. A cache miss requires an active Session; consumers own its activation. Calls without `sessionId` remain stateless.
|
||||
|
||||
The service maps `zh`, `zh-CN`, `zh-SG` and `zh-Hans` to Simplified Chinese, and `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant` to Traditional Chinese. Other tags pass through to the selected provider. Empty text returns an empty string without a network request.
|
||||
|
||||
-----
|
||||
@@ -48,9 +50,13 @@ The service maps `zh`, `zh-CN`, `zh-SG` and `zh-Hans` to Simplified Chinese, and
|
||||
<details>
|
||||
<summary>Maintainer details — click to expand</summary>
|
||||
|
||||
Google receives a form POST using the `gtx` client; Bing receives a JSON text array through Microsoft's Edge endpoint. Both receive submitted text in the request body. Cookies, HTTP redirects and automatic provider fallback are disabled. Text and translations remain transient; this service changes neither Session events nor model requests.
|
||||
Google receives a form POST using the `gtx` client; Bing receives a JSON text array through Microsoft's Edge endpoint. Both receive submitted text in the request body. Cookies, HTTP redirects and automatic provider fallback are disabled.
|
||||
|
||||
`TranslationError.code` distinguishes input limits, HTTP failures, response limits, invalid provider responses, transport failures and the service's `TRANSLATION_TIMEOUT` deadline. Diagnostics include no submitted text or provider error body. `translate()` always returns a Promise and rejects admission or provider failures. Caller cancellation and service disposal preserve their original abort reasons, including caller-owned timeout reasons. Unloading aborts accepted requests and waits for settlement. Input limits apply in both `resolve()` and `translate()`.
|
||||
Session-bound calls append provider-independent `plugin:translator/request` and `plugin:translator/result` records using the canonical plugin-record writers. A result references its request by Session sequence. Requests retain the exact source, languages, provider, recipe, and optional provider-owned metadata; results retain translated text. Anonymous recipes identify the effective configured endpoint and protocol revision. Original conversation events and main model input remain unchanged.
|
||||
|
||||
Storage reads validate translation payloads and request/result references. Only persisted successful results are reusable; failed or interrupted provider output is not retained as a result. A cache lookup uses read access and does not activate an Agent or publish a migrated generation. A cache miss appends through the active Session's existing writer with `appendPluginRecord`, flushes that Session, and confirms durability through storage. The translator never opens a write handle or activates a Session. Identical concurrent translations wait for the first attempt and its accepted writes, even if its consumer has already canceled or timed out, then reuse its durable result; different fragments and Sessions can make requests in parallel. Plugin records have best-effort retention across future Session-format migrations.
|
||||
|
||||
`TranslationError.code` distinguishes input limits, HTTP failures, response limits, invalid provider responses, transport failures, missing durable Sessions, storage failures, and the service's `TRANSLATION_TIMEOUT` deadline. `TRANSLATION_SESSION_INACTIVE` rejects an uncached inactive Session before any provider call. Diagnostics include no submitted text or provider error body. `translate()` always returns a Promise and rejects admission or provider failures. Caller cancellation and service disposal preserve their original abort reasons, including caller-owned timeout reasons. Queued cancellation rejects promptly; unloading aborts accepted requests and waits for their underlying storage and network work to settle. Input limits apply in both `resolve()` and `translate()`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -66,7 +72,7 @@ Google receives a form POST using the `gtx` client; Bing receives a JSON text ar
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
None, as translation is transient and does not contribute to model requests or Session logs.
|
||||
None, as anonymous endpoint translation sends no model requests and its saved records do not change model history.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -77,7 +83,10 @@ No direct effect; translation does not change model history.
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- These unofficial browser endpoints have no supported third-party API guarantee. They may rate-limit requests, become unavailable or change response formats. Availability from mainland China depends on the user's network; the service guarantees no region-wide connectivity.
|
||||
- Unsupported language tags fail through the selected provider. Consumers own paragraph scheduling, input splitting, caching and any display fallback; this service performs one request and never retries or switches providers.
|
||||
- Unsupported language tags fail through the selected provider. Consumers own Session activation, paragraph scheduling, input splitting and display fallback. The service reuses matching durable successful results; an admitted cache miss performs one request and never retries or switches providers.
|
||||
- Each fragment lookup scans the full Session log and rebuilds its translation index. Long logs with many fragments can delay display. No incremental index is retained.
|
||||
- Failed or interrupted attempts remain as append-only request records; a retry appends a new request sequence before dispatch rather than reusing an unfinished attempt.
|
||||
- A malformed translation record or a result referencing a missing request rejects all Session-bound translation lookups with `TRANSLATION_STORAGE_ERROR`; the service does not skip or repair those records. Calls without `sessionId` do not read them.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "通过无需登录的 Bing 或 Google 端点翻译临时文本,默认使用 Bing。"
|
||||
description: "通过无需登录的 Bing 或 Google 端点翻译文本,可选将结果持久保存到 Session。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
将本包挂载为 Cordis 服务。先调用 `resolve({ text, targetLanguage, sourceLanguage?, provider? })`,再调用 `translate(spec, signal?)`,结果为翻译后的纯文本。省略源语言时使用 `auto`;省略提供者时使用配置中的 `provider`,初始为 `bing`。消费者可以读取 `maxTextChars`,在提交前拆分较长文本。
|
||||
将本包挂载为 Cordis 服务。先调用 `resolve({ text, targetLanguage, sourceLanguage?, provider?, sessionId? })`,再调用 `translate(spec, signal?)`,结果为翻译后的纯文本。省略源语言时使用 `auto`;省略提供者时使用配置中的 `provider`,初始为 `bing`。消费者可以读取 `maxTextChars`,在提交前拆分较长文本。
|
||||
|
||||
| 配置 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
@@ -38,6 +38,8 @@ kind: "package-reference"
|
||||
|
||||
端点接受不含凭据或片段的 HTTP(S) URL。原生 `fetch` 使用 Host 的全局分发器及 `dsh` 启动器安装的 HTTP 代理策略。无需新增翻译库。
|
||||
|
||||
传入现有的 `sessionId`,即可将翻译请求和成功结果保存在该 Session 日志中。`translate()` 返回成功结果之前会完成持久化刷新;在精确原文、源语言、目标语言、提供方和翻译配方匹配时,重新挂载或进程重启后会复用已有结果。已保存结果只需持久 Session 存储即可读取。缓存未命中时要求 Session 已激活;消费者负责激活。不传 `sessionId` 的调用仍不保存状态。
|
||||
|
||||
服务将 `zh`、`zh-CN`、`zh-SG` 和 `zh-Hans` 映射为简体中文,将 `zh-TW`、`zh-HK`、`zh-MO` 和 `zh-Hant` 映射为繁体中文。其他标签原样传给所选提供者。空文本直接返回空字符串,不发送网络请求。
|
||||
|
||||
-----
|
||||
@@ -48,9 +50,13 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>维护者细节 — 点击展开</summary>
|
||||
|
||||
Google 接收使用 `gtx` 客户端的表单 POST 请求;Bing 通过 Microsoft Edge 端点接收 JSON 文本数组。两者都通过请求正文接收提交的文本。Cookie、HTTP 重定向和自动切换提供者均已禁用。文本和译文保持临时状态;本服务不会修改 Session 事件或模型请求。
|
||||
Google 接收使用 `gtx` 客户端的表单 POST 请求;Bing 通过 Microsoft Edge 端点接收 JSON 文本数组。两者都通过请求正文接收提交的文本。Cookie、HTTP 重定向和自动切换提供者均已禁用。
|
||||
|
||||
`TranslationError.code` 区分输入限制、HTTP 失败、响应限制、无效的提供者响应、传输失败及服务自身的 `TRANSLATION_TIMEOUT` 截止时间。诊断不包含提交的文本或提供者错误正文。`translate()` 始终返回 Promise,准入或提供者失败都会使其拒绝。调用者取消和服务卸载保留原始中止原因,包括调用者自身的超时原因。卸载时中止已接受的请求并等待它们结束。`resolve()` 和 `translate()` 都执行输入限制。
|
||||
绑定 Session 的调用通过规范的插件记录写入器追加与提供方无关的 `plugin:translator/request` 和 `plugin:translator/result` 记录。结果通过 Session 序号引用请求。请求保留精确原文、语言、提供方、翻译配方以及可选的提供方请求元数据;结果保留译文。匿名提供方的配方标识有效配置端点及协议修订。原始对话事件和主模型输入保持不变。
|
||||
|
||||
存储读取会校验翻译字段及请求与结果的引用。只有已持久保存的成功结果可复用;失败或中断的提供方输出不作为结果保存。缓存查找使用只读访问,不激活 Agent,也不发布迁移后的 generation。缓存未命中时,通过已激活 Session 的现有写入器调用 `appendPluginRecord` 追加记录,刷新该 Session,并从存储中确认持久化。translator 从不打开写句柄,也不激活 Session。相同翻译的并发请求会等待首次尝试及其已接纳的写入完成,即使其调用方已取消或超时,随后复用其持久结果;不同片段和 Session 可以并行发送请求。插件记录在未来 Session 格式迁移中仅提供尽力保留。
|
||||
|
||||
`TranslationError.code` 区分输入限制、HTTP 失败、响应限制、无效提供方响应、传输失败、缺少持久 Session、存储失败以及服务自身的 `TRANSLATION_TIMEOUT` 超时。未激活 Session 缓存未命中时,在调用任何提供方之前以 `TRANSLATION_SESSION_INACTIVE` 拒绝。诊断不包含提交的文本或提供者错误正文。`translate()` 始终返回 Promise,准入或提供者失败都会使其拒绝。调用者取消和服务卸载保留原始中止原因,包括调用者自身的超时原因。排队期间取消会立即返回拒绝;卸载会取消已接纳的请求,并等待底层存储和网络工作结束。`resolve()` 和 `translate()` 都执行输入限制。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -66,7 +72,7 @@ Google 接收使用 `gtx` 客户端的表单 POST 请求;Bing 通过 Microsoft
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
无,因为翻译是临时操作,不会加入模型请求或 Session 日志。
|
||||
无,因为匿名端点翻译不发送模型请求,其保存记录也不改变模型历史。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
@@ -77,7 +83,10 @@ Google 接收使用 `gtx` 客户端的表单 POST 请求;Bing 通过 Microsoft
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 这些非官方浏览器端点不保证支持第三方 API 调用。它们可能限流、不可用或更改响应格式。在中国大陆的可用性取决于用户网络;服务不保证覆盖整个地区的连接能力。
|
||||
- 不支持的语言标签由所选提供者报错。消费者负责段落调度、输入拆分、缓存及显示回退;本服务只执行一次请求,不重试或切换提供者。
|
||||
- 不支持的语言标签由所选提供者报错。消费者负责 Session 激活、段落调度、输入拆分和显示回退。服务复用匹配的持久成功结果;通过准入检查的缓存未命中请求执行单次调用,从不重试或切换提供方。
|
||||
- 每个片段的查找都会扫描完整 Session 日志并重建翻译索引。较长日志中的大量片段可能延迟展示。不保留增量索引。
|
||||
- 失败或中断的尝试仍作为仅追加的请求记录保留;重试在派发前追加新的请求序号,不复用未完成尝试。
|
||||
- 格式错误的翻译记录或引用不存在请求的结果会使该 Session 的所有绑定翻译查找均以 `TRANSLATION_STORAGE_ERROR` 拒绝;服务不跳过或修复这些记录。不传 `sessionId` 的调用不读取这些记录。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-experimental-translator",
|
||||
"description": "Cancellation-aware anonymous Google and Bing text translation",
|
||||
"description": "Machine translation with reusable Session results",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": { "access": "public" },
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
@@ -12,23 +14,42 @@
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" },
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts"],
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": { "@deepseek-ai/schemastery": "workspace:~" },
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*"
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:*",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*"
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:~"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
/** Transient text translation through configurable anonymous Google and Bing endpoints. */
|
||||
/** Anonymous text translation with optional durable provider-independent Session results. */
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { TranslationProvider, TranslationRequest, TranslationSpec } from './types.ts'
|
||||
import { TranslationError } from './error.ts'
|
||||
import { translateText } from './provider.ts'
|
||||
import { anonymousRecipe, translateText } from './provider.ts'
|
||||
import { TranslationStorage } from './storage.ts'
|
||||
import { addAbortListener } from 'node:events'
|
||||
|
||||
export type * from './types.ts'
|
||||
export { TranslationError } from './error.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Experimental transient text translator. */
|
||||
/** Experimental text translator with optional durable Session reuse. */
|
||||
translator: Translator
|
||||
}
|
||||
}
|
||||
@@ -51,10 +53,12 @@ export default class Translator extends Service {
|
||||
})
|
||||
|
||||
private readonly lifetime = new AbortController()
|
||||
private readonly pending = new Set<Promise<string>>()
|
||||
private readonly pending = new Set<Promise<void>>()
|
||||
private readonly storage: TranslationStorage
|
||||
|
||||
constructor(ctx: Context, private readonly config: Config) {
|
||||
super(ctx, 'translator')
|
||||
this.storage = new TranslationStorage(ctx)
|
||||
ctx.effect(() => async () => {
|
||||
this.lifetime.abort(new Error('Translator service disposed'))
|
||||
await Promise.allSettled(this.pending)
|
||||
@@ -73,23 +77,45 @@ export default class Translator extends Service {
|
||||
this.lifetime.signal.throwIfAborted()
|
||||
this.assertTextLimit(request.text)
|
||||
return { text: request.text, targetLanguage: request.targetLanguage,
|
||||
sourceLanguage: request.sourceLanguage ?? 'auto', provider: request.provider ?? this.config.provider }
|
||||
sourceLanguage: request.sourceLanguage ?? 'auto', provider: request.provider ?? this.config.provider,
|
||||
...request.sessionId === undefined ? {} : { sessionId: request.sessionId } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one resolved specification; the selected provider receives its text.
|
||||
* @param spec - complete routing and language choices from `resolve()`.
|
||||
* @param signal - optional caller cancellation, combined with service disposal.
|
||||
* @returns translated plain text; rejects provider/limit failures and preserves cancellation reasons.
|
||||
* @returns translated plain text, durably retained before return when a Session is supplied.
|
||||
* An uncached supplied Session must be active; otherwise rejects with `TRANSLATION_SESSION_INACTIVE` before dispatch.
|
||||
* Rejects provider/storage/limit failures and preserves cancellation reasons.
|
||||
*/
|
||||
async translate(spec: TranslationSpec, signal?: AbortSignal): Promise<string> {
|
||||
this.lifetime.signal.throwIfAborted()
|
||||
this.assertTextLimit(spec.text)
|
||||
const combined = signal === undefined ? this.lifetime.signal : AbortSignal.any([signal, this.lifetime.signal])
|
||||
combined.throwIfAborted()
|
||||
const task = translateText(spec, this.config, combined)
|
||||
this.pending.add(task)
|
||||
return task.finally(() => { this.pending.delete(task) })
|
||||
const identity = { provider: spec.provider, text: spec.text, sourceLanguage: spec.sourceLanguage,
|
||||
targetLanguage: spec.targetLanguage, recipe: anonymousRecipe(spec.provider, this.config) }
|
||||
const task = spec.sessionId === undefined ? translateText(spec, this.config, combined)
|
||||
: this.storage.run(spec.sessionId, identity, combined, async (log) => {
|
||||
const requestSeq = await log.request(identity)
|
||||
combined.throwIfAborted()
|
||||
const text = await translateText(spec, this.config, combined)
|
||||
combined.throwIfAborted()
|
||||
await log.result(requestSeq, text)
|
||||
return text
|
||||
})
|
||||
const settled = task.then(() => {}, () => {})
|
||||
this.pending.add(settled)
|
||||
void settled.then(() => { this.pending.delete(settled) })
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const listener = addAbortListener(combined, () => {
|
||||
// Preserve the caller's arbitrary abort reason while accepted work remains owned until settlement.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
reject(combined.reason)
|
||||
})
|
||||
void task.then(resolve, reject).finally(() => { listener[Symbol.dispose]() })
|
||||
})
|
||||
}
|
||||
|
||||
private assertTextLimit(text: string): void {
|
||||
|
||||
@@ -4,6 +4,20 @@ import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import type { Config } from './index.ts'
|
||||
import type { TranslationProvider, TranslationSpec } from './types.ts'
|
||||
import { TranslationError } from './error.ts'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Identify anonymous request semantics without provider-specific language normalization.
|
||||
* @param provider - explicitly resolved endpoint route.
|
||||
* @param config - deployment endpoints; raw endpoint values are excluded from stored recipes.
|
||||
* @returns protocol revision and effective endpoint fingerprint for durable result reuse.
|
||||
*/
|
||||
export function anonymousRecipe(provider: TranslationProvider, config: Config): string {
|
||||
const url = new URL(provider === 'google' ? config.googleEndpoint : config.bingEndpoint)
|
||||
const owned = provider === 'google' ? ['client', 'sl', 'tl', 'dt', 'q'] : ['from', 'to', 'isEnterpriseClient']
|
||||
for (const key of owned) url.searchParams.delete(key)
|
||||
return JSON.stringify([provider === 'google' ? 'google-form-v1' : 'bing-edge-v1', createHash('sha256').update(url.href).digest('hex')])
|
||||
}
|
||||
|
||||
function language(tag: string, provider: TranslationProvider): string {
|
||||
const lower = tag.toLowerCase()
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/** Durable provider-independent translations through an existing Session writer. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { appendPluginRecord, pluginRecordOf, SessionSeq, type SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionPersistenceNotFoundError, type SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { deepEqualJson, deepFreeze } from '@deepseek-ai/dsh-util-values'
|
||||
import type { TranslationIdentity, TranslationRequestRecord } from './types.ts'
|
||||
import { TranslationError } from './error.ts'
|
||||
|
||||
/** One live Session's translation writes; failures preserve safe diagnostics or the caller's abort reason. */
|
||||
export interface TranslationLog {
|
||||
/**
|
||||
* Retain one immutable request before dispatch.
|
||||
* @param data - common input and optional exact provider-owned request details.
|
||||
* @returns the request sequence used by its eventual result, after durability is verified.
|
||||
*/
|
||||
request(data: TranslationRequestRecord): Promise<SessionSeq>
|
||||
/**
|
||||
* Retain a successful result before returning it to the caller.
|
||||
* @param requestSeq - this operation's durable request sequence.
|
||||
* @param text - complete validated translated text.
|
||||
*/
|
||||
result(requestSeq: SessionSeq, text: string): Promise<void>
|
||||
}
|
||||
|
||||
function required(): TranslationError {
|
||||
return new TranslationError('TRANSLATION_SESSION_REQUIRED', 'Translation requires an existing durable Session')
|
||||
}
|
||||
|
||||
function invalid(): TranslationError {
|
||||
return new TranslationError('TRANSLATION_STORAGE_ERROR', 'Stored translation records are invalid or unavailable')
|
||||
}
|
||||
|
||||
function failure(error: unknown, signal: AbortSignal): never {
|
||||
signal.throwIfAborted()
|
||||
if (error instanceof SessionPersistenceNotFoundError) throw required()
|
||||
if (error instanceof TranslationError) throw error
|
||||
throw invalid()
|
||||
}
|
||||
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function requestData(value: unknown): TranslationRequestRecord {
|
||||
if (!object(value) || !['provider', 'text', 'sourceLanguage', 'targetLanguage', 'recipe'].every(key => typeof value[key] === 'string')
|
||||
|| (value.metadata !== undefined && !object(value.metadata))) throw invalid()
|
||||
return { provider: String(value.provider), text: String(value.text), sourceLanguage: String(value.sourceLanguage),
|
||||
targetLanguage: String(value.targetLanguage), recipe: String(value.recipe),
|
||||
...value.metadata === undefined ? {} : { metadata: value.metadata } }
|
||||
}
|
||||
|
||||
function identityKey(identity: TranslationIdentity): string {
|
||||
return JSON.stringify([identity.provider, identity.text, identity.sourceLanguage, identity.targetLanguage, identity.recipe])
|
||||
}
|
||||
|
||||
/** Saved results need no live Session; uncached translations use its existing writer. */
|
||||
export class TranslationStorage {
|
||||
private readonly translations = new Map<string, Promise<void>>()
|
||||
|
||||
/** @param ctx - translator context with optional live Sessions and durable persistence. */
|
||||
constructor(private readonly ctx: Context) {
|
||||
ctx.effect(() => async () => { await Promise.all(this.translations.values()) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse a saved result, or retain a new translation through the live Session writer.
|
||||
* @param id - existing Session; a cache miss without a live Session rejects with
|
||||
* `TRANSLATION_SESSION_INACTIVE` before invoking `operation`.
|
||||
* @param identity - provider, exact source, languages, and recipe; identical concurrent attempts share cache admission.
|
||||
* @param signal - operation cancellation, also observed after every storage await.
|
||||
* @param operation - uncached translation using the pinned live Session.
|
||||
* @returns saved or newly translated text, durably retained. Operation rejection returns without
|
||||
* waiting for accepted writes; identical retries and storage disposal wait for those writes to settle.
|
||||
*/
|
||||
run(id: SessionId, identity: TranslationIdentity, signal: AbortSignal,
|
||||
operation: (log: TranslationLog) => Promise<string>): Promise<string> {
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
const key = JSON.stringify([id, identityKey(identity)])
|
||||
const writes: Promise<void>[] = []
|
||||
const task = (this.translations.get(key) ?? Promise.resolve()).then(async () => {
|
||||
signal.throwIfAborted()
|
||||
if (persistence === undefined) throw required()
|
||||
const current = (): void => {
|
||||
signal.throwIfAborted()
|
||||
if (this.ctx.get('sessionPersistence')?.identity !== persistence.identity) throw required()
|
||||
}
|
||||
try {
|
||||
current()
|
||||
if (await persistence.stat(id, { signal }) === undefined) throw required()
|
||||
current()
|
||||
const cached = await this.lookup(id, identity, persistence, signal, current)
|
||||
current()
|
||||
if (cached !== undefined) return cached
|
||||
const sessions = this.ctx.get('sessions'), session = sessions?.get(id)
|
||||
if (sessions === undefined || session === undefined) {
|
||||
throw new TranslationError('TRANSLATION_SESSION_INACTIVE', 'Uncached translation requires an active Session')
|
||||
}
|
||||
const live = (): void => {
|
||||
current()
|
||||
if (this.ctx.get('sessions')?.get(id) !== session) throw required()
|
||||
}
|
||||
const append = <K extends 'plugin:translator/request' | 'plugin:translator/result'>(type: K,
|
||||
data: import('@deepseek-ai/dsh-session/types').PluginRecordMap[K]): Promise<SessionSeq> => {
|
||||
const writing = (async () => {
|
||||
try {
|
||||
live()
|
||||
const snapshot = deepFreeze(structuredClone(data))
|
||||
const seq = appendPluginRecord(session, type, snapshot)
|
||||
await sessions.flush(session)
|
||||
live()
|
||||
const stored = (await this.read(id, persistence, signal, live, seq, 1))[0]
|
||||
live()
|
||||
if (stored === undefined || pluginRecordOf(stored)?.type !== type || !deepEqualJson(stored.data, snapshot)) throw invalid()
|
||||
return seq
|
||||
} catch (error) {
|
||||
failure(error, signal)
|
||||
}
|
||||
})()
|
||||
writes.push(writing.then(() => {}, () => {}))
|
||||
return writing
|
||||
}
|
||||
return await operation({
|
||||
request: data => append('plugin:translator/request', data),
|
||||
async result(requestSeq, text) { await append('plugin:translator/result', { requestSeq, text }) },
|
||||
})
|
||||
} catch (error) {
|
||||
failure(error, signal)
|
||||
}
|
||||
})
|
||||
const settled = task.then(() => {}, () => {})
|
||||
const tail = settled.then(async () => {
|
||||
await Promise.all(writes)
|
||||
if (this.translations.get(key) === tail) this.translations.delete(key)
|
||||
})
|
||||
this.translations.set(key, tail)
|
||||
return task
|
||||
}
|
||||
|
||||
private async read(id: SessionId, persistence: SessionPersistence, signal: AbortSignal,
|
||||
current: () => void, start = 0, count?: number) {
|
||||
current()
|
||||
await using handle = await persistence.open(id, 'read', { signal })
|
||||
current()
|
||||
const stored = await handle.read(start, count, { signal })
|
||||
current()
|
||||
return stored.events
|
||||
}
|
||||
|
||||
private async lookup(id: SessionId, identity: TranslationIdentity, persistence: SessionPersistence,
|
||||
signal: AbortSignal, current: () => void): Promise<string | undefined> {
|
||||
const requests = new Map<SessionSeq, TranslationRequestRecord>()
|
||||
const results = new Map<string, string>()
|
||||
for (const event of await this.read(id, persistence, signal, current)) {
|
||||
const record = pluginRecordOf(event)
|
||||
if (record?.type === 'plugin:translator/request') requests.set(record.seq, requestData(record.data))
|
||||
if (record?.type !== 'plugin:translator/result') continue
|
||||
const data = record.data
|
||||
if (!object(data) || typeof data.requestSeq !== 'number' || !Number.isSafeInteger(data.requestSeq)
|
||||
|| data.requestSeq < 0 || Object.is(data.requestSeq, -0) || typeof data.text !== 'string') throw invalid()
|
||||
const request = requests.get(SessionSeq(data.requestSeq))
|
||||
if (request === undefined) throw invalid()
|
||||
results.set(identityKey(request), data.text)
|
||||
}
|
||||
return results.get(identityKey(identity))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Provider-independent inputs to the experimental translation service. */
|
||||
import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Anonymous browser endpoint selected for one translation. */
|
||||
export type TranslationProvider = 'google' | 'bing'
|
||||
@@ -13,6 +14,8 @@ export interface TranslationRequest {
|
||||
readonly sourceLanguage?: string
|
||||
/** Endpoint selection; omission uses the service's configured provider. */
|
||||
readonly provider?: TranslationProvider
|
||||
/** Existing Session for durable results; a cache miss requires it to be active. Omission keeps the call stateless. */
|
||||
readonly sessionId?: SessionId
|
||||
}
|
||||
|
||||
/** Fully resolved routing and language choices for one provider request. */
|
||||
@@ -25,8 +28,48 @@ export interface TranslationSpec {
|
||||
readonly sourceLanguage: string
|
||||
/** Selected anonymous browser endpoint. */
|
||||
readonly provider: TranslationProvider
|
||||
/** Existing Session for durable translation records. */
|
||||
readonly sessionId?: SessionId
|
||||
}
|
||||
|
||||
/** Provider-independent identity of one reusable translation. */
|
||||
export interface TranslationIdentity {
|
||||
/** Explicit provider route; results from different providers remain distinct. */
|
||||
readonly provider: string
|
||||
/** Exact submitted source fragment. */
|
||||
readonly text: string
|
||||
/** Requested source language, including automatic detection. */
|
||||
readonly sourceLanguage: string
|
||||
/** Requested destination language. */
|
||||
readonly targetLanguage: string
|
||||
/** Protocol, endpoint, or model/prompt settings that affect translation semantics. */
|
||||
readonly recipe: string
|
||||
}
|
||||
|
||||
/** One translation attempt, committed durably before external dispatch. */
|
||||
export interface TranslationRequestRecord extends TranslationIdentity {
|
||||
/** Optional provider-owned request details; contains no credential values. */
|
||||
readonly metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Successful translated text linked to its durable request attempt. */
|
||||
export interface TranslationResultRecord {
|
||||
/** Sequence of the preceding translator request in the same Session. */
|
||||
readonly requestSeq: SessionSeq
|
||||
/** Complete validated translation. */
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface PluginRecordMap {
|
||||
/** Provider-independent translation input, retained before dispatch. */
|
||||
'plugin:translator/request': TranslationRequestRecord
|
||||
/** Successful translation, retained before it is returned to the consumer. */
|
||||
'plugin:translator/result': TranslationResultRecord
|
||||
}
|
||||
}
|
||||
|
||||
/** Failures distinct from caller cancellation and service disposal. */
|
||||
export type TranslationErrorCode = 'TRANSLATION_TEXT_LIMIT' | 'TRANSLATION_HTTP_ERROR'
|
||||
| 'TRANSLATION_INVALID_RESPONSE' | 'TRANSLATION_RESPONSE_LIMIT' | 'TRANSLATION_REQUEST_FAILED' | 'TRANSLATION_TIMEOUT'
|
||||
| 'TRANSLATION_SESSION_REQUIRED' | 'TRANSLATION_SESSION_INACTIVE' | 'TRANSLATION_STORAGE_ERROR'
|
||||
@@ -0,0 +1,163 @@
|
||||
/** Loader-owned anonymous provider, live Sessions, and restartable real JSONL storage. */
|
||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
||||
import type { ModuleLoaderV2 } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { boot } from '@deepseek-ai/dsh-app-boot'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type Session, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence'
|
||||
import JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import Translator from '../src/index.ts'
|
||||
|
||||
/** One external anonymous request, with only the relevant protocol facts. */
|
||||
export interface StorageWireCall { provider: 'bing' | 'google'; path: string; text: string; source: string; target: string }
|
||||
/** Fixture-owned response control; no Host or model requests are intercepted. */
|
||||
export interface StorageFixtureOptions {
|
||||
reply?: (call: StorageWireCall, response: ServerResponse) => void | Promise<void>
|
||||
withPersistence?: boolean
|
||||
withSessions?: boolean
|
||||
}
|
||||
|
||||
/** JSON response matching the selected external translation protocol. */
|
||||
export function storageReply(call: StorageWireCall, response: ServerResponse, text = 'Stored translation'): void {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify(call.provider === 'bing'
|
||||
? [{ translations: [{ text, to: call.target }] }]
|
||||
: [[[text, call.text, null, null]], null, 'en']))
|
||||
}
|
||||
|
||||
function mainEvents(): SessionEvent[] {
|
||||
return [
|
||||
{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
|
||||
{ type: 'user/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append',
|
||||
data: createUserMessage({ content: [{ type: 'text', text: 'Main conversation' }], source: { kind: 'user' } }) },
|
||||
{ type: 'step/start', seq: SessionSeq(2), time: 3, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: SessionSeq(3), time: 4, surfaceOp: 'append', data: { turn: 1, step: 1, stream: [],
|
||||
message: createAssistantMessage({ content: [{ type: 'reasoning', text: 'Original paragraph' }],
|
||||
source: { provider: 'main-provider', model: 'main-model' } }) } },
|
||||
{ type: 'step/end', seq: SessionSeq(4), time: 5, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: SessionSeq(5), time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create private, atomically allocated resources and register teardown before use.
|
||||
* @param cleanups - owning test's reverse-order quiescent cleanup list.
|
||||
* @param options - external reply control and optional storage omission.
|
||||
* @returns the real Host composition, restart operation, and Session/storage observations.
|
||||
*/
|
||||
export async function storageFixture(cleanups: Array<() => Promise<void>>, options: StorageFixtureOptions = {}) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-translator-storage-'))
|
||||
cleanups.push(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
const requests: StorageWireCall[] = [], errors: unknown[] = [], handlers = new Set<Promise<void>>()
|
||||
const respond = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of request) {
|
||||
if (!(chunk instanceof Buffer)) throw new Error('Expected HTTP bytes')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
const url = new URL(request.url ?? '/', 'http://translation.test')
|
||||
const body = Buffer.concat(chunks).toString('utf8')
|
||||
const provider = url.pathname.startsWith('/google') ? 'google' : 'bing'
|
||||
const form = new URLSearchParams(body)
|
||||
const value: unknown = provider === 'bing' ? JSON.parse(body) : undefined
|
||||
if (provider === 'bing' && (!Array.isArray(value) || typeof value[0] !== 'string')) throw new Error('Expected Bing text array')
|
||||
const text = provider === 'google' ? form.get('q') ?? '' : String(Array.isArray(value) ? value[0] : '')
|
||||
const call: StorageWireCall = { provider, path: url.pathname, text,
|
||||
source: provider === 'google' ? form.get('sl') ?? '' : url.searchParams.get('from') ?? 'auto',
|
||||
target: provider === 'google' ? form.get('tl') ?? '' : url.searchParams.get('to') ?? '' }
|
||||
requests.push(call)
|
||||
if (options.reply === undefined) storageReply(call, response)
|
||||
else await options.reply(call, response)
|
||||
}
|
||||
const server = createServer((request, response) => {
|
||||
const work = respond(request, response).catch((error: unknown) => { errors.push(error); response.destroy() })
|
||||
handlers.add(work)
|
||||
void work.then(() => { handlers.delete(work) })
|
||||
})
|
||||
cleanups.push(async () => {
|
||||
const closed = new Promise<void>((resolve, reject) => server.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
}))
|
||||
server.closeAllConnections()
|
||||
await closed
|
||||
await Promise.all(handlers)
|
||||
})
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('Expected allocated loopback listener')
|
||||
const origin = `http://127.0.0.1:${address.port}`, path = join(root, 'cordis.yml'), storedRoot = join(root, 'sessions')
|
||||
const config = { bingEndpoint: `${origin}/bing`, googleEndpoint: `${origin}/google`, timeoutMs: 60_000 }
|
||||
await writeFile(path, JSON.stringify([
|
||||
...options.withSessions === false ? [] : [{ id: 'sessions', name: '@deepseek-ai/dsh-session' }],
|
||||
...options.withPersistence === false ? [] : [
|
||||
{ id: 'storage', name: '@deepseek-ai/dsh-session-persistence-jsonl', config: { root: storedRoot, compression: 'none' } },
|
||||
],
|
||||
{ id: 'translator', name: '@deepseek-ai/dsh-experimental-translator', config },
|
||||
]))
|
||||
const start = () => boot('translator-storage', path, [], (host) => {
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-persistence-jsonl', JsonlPersistence],
|
||||
['@deepseek-ai/dsh-experimental-translator', Translator],
|
||||
])
|
||||
const loader: ModuleLoaderV2 = {
|
||||
version: 'v2', loadCache: new Map(),
|
||||
async import(specifier) {
|
||||
if (!modules.has(specifier)) throw new Error(`Unexpected fixture import ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
register(): never { throw new Error('Unexpected module hook registration') },
|
||||
getOrCreateModuleJob(): never { throw new Error('Unexpected module job creation') },
|
||||
resolveSync(): never { throw new Error('Unexpected synchronous module resolution') },
|
||||
load(): never { throw new Error('Unexpected module load') },
|
||||
}
|
||||
host.loader.internal = loader
|
||||
})
|
||||
let ctx = await start()
|
||||
cleanups.push(async () => { await ctx.fiber.dispose() })
|
||||
const readEvents = async (id: SessionId): Promise<readonly SessionEvent[]> => {
|
||||
await using handle = await ctx.sessionPersistence.open(id, 'read')
|
||||
return (await handle.read()).events
|
||||
}
|
||||
const createSession = async (id: SessionId = SessionId('stored-translation'), live = true) => {
|
||||
const events = mainEvents()
|
||||
const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id, createdAt: 1, cwd: root, isSeeded: false, delegationDepth: 0 }
|
||||
let session: Session | undefined, scope: Fiber | undefined, writer: SessionHandle | undefined
|
||||
if (live) {
|
||||
scope = ctx.plugin({ inject: ['sessions'], apply(owner: Context) {
|
||||
session = owner.sessions.create(id, { meta: { createdAt: 1, cwd: root, isSeeded: false, delegationDepth: 0 } })
|
||||
} })
|
||||
await scope.await()
|
||||
if (session === undefined) throw new Error('Live Session was not published')
|
||||
writer = await ctx.sessionPersistence.create(session.header)
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'user/message': session.append(event.type, event.data, { surfaceOp: 'append' }); break
|
||||
case 'assistant/message': session.append(event.type, event.data, { surfaceOp: 'append' }); break
|
||||
case 'turn/start': session.append(event.type, event.data); break
|
||||
case 'step/start': session.append(event.type, event.data); break
|
||||
case 'step/end': session.append(event.type, event.data); break
|
||||
case 'turn/end': session.append(event.type, event.data); break
|
||||
default: throw new Error('Unexpected main fixture event')
|
||||
}
|
||||
}
|
||||
await ctx.sessions.flush(session)
|
||||
} else {
|
||||
await using handle = await ctx.sessionPersistence.create(header)
|
||||
await handle.append(events)
|
||||
await handle.flush()
|
||||
}
|
||||
const artifacts = (await readdir(storedRoot, { recursive: true })).filter(value => value.endsWith(join(id, `session.v${SESSION_FORMAT_VERSION}.jsonl`)))
|
||||
if (artifacts.length !== 1) throw new Error('Expected one current Session artifact')
|
||||
const file = join(storedRoot, artifacts[0]!)
|
||||
return { id, session, scope, writer, events: await readEvents(id), file, bytes: await readFile(file) }
|
||||
}
|
||||
return { get ctx() { return ctx }, root, config, requests, errors, readEvents, createSession,
|
||||
async restart() { await ctx.fiber.dispose(); ctx = await start() } }
|
||||
}
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user