diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c5e1c6..ac9576f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Docs: add a worked adapter that shows the official COT translation in the reading view through the text-transform seam ([`docs/extension-points.md`](docs/extension-points.md), [`examples/cot-translation-reader/`](examples/cot-translation-reader/)). The official bundle registers its translation on `conversation.chat.reasoning.body`, which the reading view replaces, so that bundle alone cannot paint there. + ## 0.3.9 — 2026-10-27 - Keep assistant narration visible. Collapsing process hides reasoning, tool calls and process records, and a compact seam between body blocks summarises the folded run (`思考×N · 工具×K · 记录×M`) and expands those rows in place. During a live turn, a later reasoning step folds each earlier reasoning/tool run in its own position, including across narration, without counting narration as `输出×N`. Thanks @Adamaik (#68). diff --git a/docs/extension-points.md b/docs/extension-points.md index fa6c102..5b62764 100644 --- a/docs/extension-points.md +++ b/docs/extension-points.md @@ -144,6 +144,34 @@ function publish(blockKey: string, text: string, final: boolean) { `publish` after each batch. Extend the previous cached string when the fade should continue. +### 接官方思考翻译(`cotTranslation`) + +官方「思考过程机器翻译」组合包(`@deepseek-ai/dsh-experimental-cot-translation-bundle`)把译文注册在 `conversation.chat.reasoning.body` 插槽上,而阅读页用自己的卡片渲染思考,那个插槽不会渲染——装了翻译包,在阅读页看起来像没翻译。用这条缝接它: + +```ts +const cot = ctx.get('cotTranslation') as { + limits(signal: AbortSignal): Promise<{ + maxTextChars: number; + preferences: { provider: string; targetLanguage: string }; + }>; + translate( + request: { text: string; targetLanguage: string; provider?: string }, + signal: AbortSignal, + ): Promise; +} | undefined; +``` + +先调 `limits()` 拿目标语言与单次上限(这一步不发送文本),再按 `maxTextChars` 切出**已完成**段落逐段 `translate()`。四个容易踩的点: + +- `read` 必须同步:返回缓存或 `undefined`(保留原文),把请求排进 `queueMicrotask`,完成后用 `subscribe` 通知重绘; +- 流式中的末段不发,否则会把半句话送去翻译; +- 结果按 `blockKey` 缓存,源文本变长时只请求新增段落; +- 失败返回 `undefined`,阅读页保留原文;不要抛错,也不要写回 block。 + +组合包没挂载时 `ctx.get('cotTranslation')` 返回 `undefined`,此时直接返回 `undefined` 即可,界面与未安装插件完全一致。 + +完整可运行版本(分片、缓存、失败回退、卸载中止)见 [`examples/cot-translation-reader/`](../examples/cot-translation-reader/)。 + ## `dsh-better-display.block` Chain slot, `scope: 'session'`. The reader calls `renderSlotChain` once per block. The first selector that returns non-null draws the block. All-null renders the reader fallback, which is the path that keeps reveal, follow-scroll, and fold. diff --git a/examples/cot-translation-reader/README.md b/examples/cot-translation-reader/README.md new file mode 100644 index 0000000..ee6a284 --- /dev/null +++ b/examples/cot-translation-reader/README.md @@ -0,0 +1,37 @@ +# COT translation adapter (reference) + +把一个**官方**翻译能力接到阅读页的示例:官方「思考过程机器翻译」组合包 +(`@deepseek-ai/dsh-experimental-cot-translation-bundle`)把译文注册在 +`conversation.chat.reasoning.body` 插槽上,而阅读页用自己的卡片渲染思考,那个 +插槽不会渲染。这个示例改用 `dsh-better-display.text-transform` 缝,把译文交给 +阅读页绘制。 + +它是**示例**,不是发布物:没有对应 npm 包,也不参与本仓库的 `npm run build`。 +复制到自己的插件里,或用 `dsh plugin --profile web add ./examples/cot-translation-reader` +在本地试。 + +## 前提 + +- `dsh-better-display` ≥ **0.3.9**(`dsh-better-display.text-transform` 缝从这版开始提供) +- 官方 COT 翻译组合包已启用,并在插件面板选好翻译服务与目标语言 + +任一个缺失时插件安静地不生效:不报错,也不改界面。 + +## 关键点 + +| 点 | 为什么 | +| --- | --- | +| `read` 保持同步 | 每次绘制都会调用;请求排进微任务,结果通过 `subscribe` 通知重绘 | +| 只翻已完成段落 | 流式中的最后一段是半句话,发出去既浪费也不准 | +| 按 `maxTextChars` 切分 | 上限来自 `ctx.cotTranslation.limits()`,不是猜的 | +| 按 `blockKey` 缓存 | 源文本变长时只请求新增段落,已翻好的不重复发 | +| 失败返回 `undefined` | 阅读页保留原文;抛错会被缝捕获并回退原文,但日志更难读 | +| 卸载时停止 | 插件撤下后不再有绘制,继续请求只是浪费 | + +## 文件 + +- [`src/client/index.ts`](src/client/index.ts):缝注册、分片、异步批次、缓存 +- [`cordis.patch.yml`](cordis.patch.yml):把示例挂进 profile +- [`tsconfig.json`](tsconfig.json):单独类型检查用(`npx tsc -p examples/cot-translation-reader`) + +完整说明见 [`docs/extension-points.md`](../../docs/extension-points.md)。 diff --git a/examples/cot-translation-reader/cordis.patch.yml b/examples/cot-translation-reader/cordis.patch.yml new file mode 100644 index 0000000..25d7668 --- /dev/null +++ b/examples/cot-translation-reader/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: cot-translation-example + name: dsh-cot-translation-example diff --git a/examples/cot-translation-reader/package.json b/examples/cot-translation-reader/package.json new file mode 100644 index 0000000..754940e --- /dev/null +++ b/examples/cot-translation-reader/package.json @@ -0,0 +1,14 @@ +{ + "name": "dsh-cot-translation-example", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Reference adapter: register the official COT translation on the reading view's text-transform seam.", + "dsh": { + "bundle": { "patch": "./cordis.patch.yml" }, + "client": { "platform": "web" } + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1" + } +} diff --git a/examples/cot-translation-reader/src/client/index.ts b/examples/cot-translation-reader/src/client/index.ts new file mode 100644 index 0000000..195e5eb --- /dev/null +++ b/examples/cot-translation-reader/src/client/index.ts @@ -0,0 +1,179 @@ +/** + * Reference adapter: show the official COT translation in the reading view. + * + * The official bundle registers translated reasoning on + * `conversation.chat.reasoning.body`, a slot the reading view replaces. This + * plugin registers on `dsh-better-display.text-transform` instead and lets the + * reading view paint the result. Display-only: the session, the transcript and + * the fold keep the original text. + */ +import type { Context } from '@deepseek-ai/cordis'; + +const SEAM = 'dsh-better-display.text-transform'; +const COT = 'cotTranslation'; + +interface Request { + text: string; + streaming: boolean; + blockKey: string; + kind: 'reasoning' | 'body'; + source?: 'assistant' | 'user' | 'tool'; +} + +interface Display { + text: string; + final: boolean; +} + +interface Seam { + register(kind: 'reasoning', transformer: { + read(request: Request): Display | undefined; + subscribe(listener: () => void): () => void; + }): () => void; +} + +interface CotService { + limits(signal: AbortSignal): Promise<{ maxTextChars: number; preferences: { provider: string; targetLanguage: string } }>; + translate(request: { text: string; targetLanguage: string; provider?: string }, signal: AbortSignal): Promise; +} + +/** One request-sized slice and the whitespace that stays verbatim after it. */ +interface Fragment { + body: string; + suffix: string; +} + +/** Completed paragraphs only; a running tail is never submitted. */ +export function fragments(text: string, running: boolean, limit: number): { list: Fragment[]; tail: string } { + const list: Fragment[] = []; + const parts = text.split(/(\n[ \t]*\n[ \t]*)/); + let tail = ''; + for (let index = 0; index < parts.length; index += 2) { + const body = parts[index]; + const suffix = parts[index + 1] ?? ''; + const last = index + 2 >= parts.length; + if (last && running) { + tail = body + suffix; + break; + } + if (body.trim() === '') { + if (list.length > 0) list[list.length - 1].suffix += body + suffix; + continue; + } + let rest = body; + while (rest.length > limit) { + let cut = rest.lastIndexOf(' ', limit); + if (cut < limit / 2) cut = limit; + list.push({ body: rest.slice(0, cut), suffix: '' }); + rest = rest.slice(cut); + } + list.push({ body: rest, suffix }); + } + return { list, tail }; +} + +export const name = 'cot-translation-example'; +export const inject = [SEAM]; + +export function apply(ctx: Context): () => void { + const get = (name: string) => (ctx as unknown as { get?: (key: string) => unknown }).get?.call(ctx, name); + const seam = get(SEAM) as Seam | undefined; + const cot = () => get(COT) as CotService | undefined; + if (!seam) return () => {}; + + const listeners = new Set<() => void>(); + const notify = () => { for (const listener of [...listeners]) listener(); }; + interface Entry { + source: string; + list: Fragment[]; + tail: string; + results: Map; + shown?: string; + pending: boolean; + dirty: boolean; + } + const states = new Map(); + let target: { limit: number; provider: string; language: string } | undefined; + let preparing = false; + let disposed = false; + + const paint = (state: Entry): string | undefined => { + if (state.results.size === 0) return undefined; + let text = ''; + for (const fragment of state.list) text += (state.results.get(fragment.body) ?? fragment.body) + fragment.suffix; + return text + state.tail; + }; + + const fill = async (key: string) => { + const service = cot(); + const state = states.get(key); + if (!service || !state || !target || state.pending || disposed) return; + state.pending = true; + try { + for (const fragment of state.list) { + if (state.results.has(fragment.body) || fragment.body.trim() === '') continue; + const translated = await service.translate( + { text: fragment.body, targetLanguage: target.language, provider: target.provider }, + new AbortController().signal, + ); + if (disposed) return; + state.results.set(fragment.body, translated); + state.shown = paint(state); + notify(); + } + } catch (error) { + // Keep the source on screen; the next paint can retry. + console.warn('[cot-translation-example] translation failed; keeping the source text', error); + } finally { + state.pending = false; + } + }; + + const prepare = async () => { + const service = cot(); + if (!service || target || preparing || disposed) return; + preparing = true; + try { + const snapshot = await service.limits(new AbortController().signal); + target = { + limit: snapshot.maxTextChars, + provider: snapshot.preferences.provider, + language: snapshot.preferences.targetLanguage, + }; + notify(); + } finally { + preparing = false; + } + }; + + return seam.register('reasoning', { + read(request) { + if (disposed || request.source === 'tool' || request.kind !== 'reasoning') return undefined; + if (!cot()) return undefined; + let state = states.get(request.blockKey); + if (!state) { + state = { source: '', list: [], tail: '', results: new Map(), pending: false, dirty: false }; + states.set(request.blockKey, state); + } + if (state.source !== request.text) { + state.source = request.text; + state.dirty = true; + } + void prepare(); + if (!target) return state.shown === undefined ? undefined : { text: state.shown, final: !request.streaming }; + if (state.dirty) { + state.dirty = false; + const split = fragments(state.source, request.streaming, target.limit); + state.list = split.list; + state.tail = split.tail; + state.shown = paint(state); + void fill(request.blockKey); + } + return state.shown === undefined ? undefined : { text: state.shown, final: !request.streaming }; + }, + subscribe(listener) { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + }); +} diff --git a/examples/cot-translation-reader/tsconfig.json b/examples/cot-translation-reader/tsconfig.json new file mode 100644 index 0000000..639c58b --- /dev/null +++ b/examples/cot-translation-reader/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2024", "module": "NodeNext", "moduleResolution": "NodeNext", + "strict": true, "skipLibCheck": true, "noEmit": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node"] + }, + "include": ["src"] +}