Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
28 changes: 28 additions & 0 deletions docs/extension-points.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
} | 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.
Expand Down
37 changes: 37 additions & 0 deletions examples/cot-translation-reader/README.md
Original file line number Diff line number Diff line change
@@ -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)。
3 changes: 3 additions & 0 deletions examples/cot-translation-reader/cordis.patch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- insert:
- id: cot-translation-example
name: dsh-cot-translation-example
14 changes: 14 additions & 0 deletions examples/cot-translation-reader/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
179 changes: 179 additions & 0 deletions examples/cot-translation-reader/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}

/** 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<string, string>;
shown?: string;
pending: boolean;
dirty: boolean;
}
const states = new Map<string, Entry>();
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); };
},
});
}
9 changes: 9 additions & 0 deletions examples/cot-translation-reader/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}