diff --git a/components/notes/InlineMarkdownEditor.tsx b/components/notes/InlineMarkdownEditor.tsx
index b927c97a3e..ebceac05b7 100644
--- a/components/notes/InlineMarkdownEditor.tsx
+++ b/components/notes/InlineMarkdownEditor.tsx
@@ -2226,7 +2226,13 @@ export const InlineMarkdownEditor = React.memo(
editorMode === "preview" && "netcatty-mdx-editor--preview",
)}
contentEditableClassName="netcatty-mdx-content"
- onChange={commitMarkdown}
+ onChange={(markdown, initialMarkdownNormalize) => {
+ // Importing Markdown may escape literal comparisons on export.
+ // Keep the user's source until they actually edit the rich view.
+ if (!initialMarkdownNormalize && editorMode !== "preview") {
+ commitMarkdown(markdown);
+ }
+ }}
onError={handleMdxParseError}
/>
)}
diff --git a/components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx b/components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx
index 15b6e32b8e..89b8e6ab2d 100644
--- a/components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx
+++ b/components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
import test from "node:test";
import { JSDOM } from "jsdom";
@@ -23,6 +24,9 @@ const NOTE_MARKDOWN_MDX_CANNOT_PARSE = [
const PLAIN_NOTE_MARKDOWN = ["# Steps", "", "Promote the replica, then restart the agent."].join("\n");
+// Public author-supplied note from issue #3205. Commands are inert test content.
+const ISSUE_3205_MARKDOWN = readFileSync(new URL("./test-fixtures/issue-3205.md", import.meta.url), "utf8");
+
const setupDom = () => {
const dom = new JSDOM('
', {
pretendToBeVisual: true,
@@ -41,17 +45,51 @@ const setupDom = () => {
disconnect() {}
}
+ function LoadedImageStub() {
+ const image = window.document.createElement("img");
+ Object.defineProperty(image, "src", {
+ get: () => image.getAttribute("src") ?? "",
+ set: (src: string) => {
+ image.setAttribute("src", src);
+ queueMicrotask(() => image.dispatchEvent(new window.Event("load")));
+ },
+ });
+ return image;
+ }
+
+ Object.assign(window.Range.prototype, {
+ getClientRects: () => [],
+ getBoundingClientRect: () => new window.DOMRect(),
+ });
+
+ // CodeMirror needs these browser APIs when the full note contains code blocks.
+ window.matchMedia = (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent: () => false,
+ });
+
for (const [key, value] of Object.entries({
window,
+ Window: window.Window,
+ Image: LoadedImageStub,
document: window.document,
navigator: window.navigator,
HTMLElement: window.HTMLElement,
+ HTMLImageElement: window.HTMLImageElement,
HTMLInputElement: window.HTMLInputElement,
HTMLTextAreaElement: window.HTMLTextAreaElement,
HTMLSelectElement: window.HTMLSelectElement,
Element: window.Element,
SVGElement: window.SVGElement,
Node: window.Node,
+ DocumentFragment: window.DocumentFragment,
+ Range: window.Range,
NodeFilter: window.NodeFilter,
MutationObserver: window.MutationObserver,
CustomEvent: window.CustomEvent,
@@ -92,6 +130,7 @@ type ActiveFormats = {
type RenderEditorProps = {
value: string;
+ noteId?: string;
editorMode?: "edit" | "preview" | "source";
onActiveFormatsChange?: (formats: ActiveFormats) => void;
};
@@ -113,7 +152,7 @@ const renderEditor = async (
root.render(
{
+ const { window, cleanup } = setupDom();
+ try {
+ const value = "Memory<1GB";
+ const { rootNode, changes, unmount } = await renderEditor(window, {
+ value,
+ editorMode: "preview",
+ });
+ try {
+ assert.ok(!querySourceFallback(rootNode), "a comparison must render as ordinary text");
+ assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, value);
+ assert.deepEqual(changes, [], "reading the note must preserve its original source");
+ } finally {
+ await unmount();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test("comparison variants render in paragraphs, tables and alongside HTML", async () => {
+ const { window, cleanup } = setupDom();
+ try {
+ const value = [
+ "<1GB",
+ "",
+ "Memory<1GB, x<=2, x<-1, x<+2, x<.5, x < 3, x\\<4, x<5",
+ "",
+ "| Limit |",
+ "| --- |",
+ "| **Memory<1GB** |",
+ "",
+ 'limit<1<2',
+ "",
+ '
<3',
+ "",
+ "`literal<1 and `",
+ "",
+ "```sh",
+ "cat <<'EOF'",
+ " and x<1",
+ "EOF",
+ "```",
+ ].join("\n");
+ const { rootNode, changes, unmount } = await renderEditor(window, { value });
+ try {
+ assert.ok(!querySourceFallback(rootNode));
+ const editable = rootNode.querySelector("[contenteditable]");
+ assert.ok(editable);
+ assert.match(editable.textContent ?? "", /Memory<1GB, x<=2, x<-1, x<\+2, x<\.5, x < 3, x<4, x<5/);
+ assert.equal(editable.querySelector("td strong")?.textContent, "Memory<1GB");
+ assert.equal(editable.querySelector("a")?.getAttribute("href"), "https://example.com/");
+ assert.equal(editable.querySelector("a")?.textContent, "limit<1");
+ const image = editable.querySelector("img");
+ assert.equal(image?.getAttribute("alt"), "limit");
+ assert.equal(image?.getAttribute("width"), "80");
+ assert.equal(image?.getAttribute("height"), "40");
+ assert.equal(editable.querySelector("code")?.textContent, "literal<1 and ");
+ const { getCodeMirrorBlockText } = await import("./InlineMarkdownEditor.tsx");
+ const codeBlock = editable.querySelector(".cm-editor");
+ assert.ok(codeBlock);
+ assert.equal(getCodeMirrorBlockText(codeBlock), "cat <<'EOF'\n and x<1\nEOF");
+ assert.deepEqual(changes, []);
+ } finally {
+ await unmount();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test("issue 3205 author note survives source, preview and edit without rewriting", async () => {
+ const { window, cleanup } = setupDom();
+ try {
+ const value = ISSUE_3205_MARKDOWN;
+ const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value, editorMode: "source" });
+ try {
+ assert.equal(rootNode.querySelector("textarea")?.value, value);
+ for (const editorMode of ["preview", "edit", "preview"] as const) {
+ await rerender({ value, editorMode });
+ await runWithAct(async () => { await new Promise((resolve) => setTimeout(resolve, 30)); });
+ assert.ok(!querySourceFallback(rootNode), `${editorMode} must render the author's note`);
+ const editable = rootNode.querySelector("[contenteditable]");
+ assert.ok(editable);
+ assert.equal(editable.getAttribute("contenteditable"), editorMode === "edit" ? "true" : "false");
+ assert.equal(editable.querySelector("h1")?.textContent, "设备TCP栈调优操作记录");
+ assert.match(editable.querySelector("table")?.textContent ?? "", /内存<1GB设备/);
+ assert.match(editable.textContent ?? "", /配置回滚/);
+ assert.equal(editable.querySelectorAll(".cm-editor").length, 10);
+ }
+ await rerender({ value, editorMode: "source" });
+ assert.equal(rootNode.querySelector("textarea")?.value, value);
+ assert.deepEqual(changes, [], "view changes must not rewrite the author's source");
+ } finally {
+ await unmount();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test("editing a comparison emits Markdown that can be reopened", async () => {
+ const { window, cleanup } = setupDom();
+ try {
+ const value = "Memory<1GB";
+ const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value });
+ try {
+ assert.deepEqual(changes, []);
+ const editable = rootNode.querySelector("[contenteditable]");
+ assert.ok(editable);
+ const { getNearestEditorFromDOMNode, $getRoot, $isTextNode } = await import("lexical");
+ const editor = getNearestEditorFromDOMNode(editable);
+ assert.ok(editor);
+ await runWithAct(async () => {
+ editor.update(() => {
+ const text = $getRoot().getFirstDescendant();
+ assert.ok($isTextNode(text));
+ text.setTextContent("Memory<2GB");
+ }, { discrete: true });
+ });
+ assert.ok(changes.length > 0, "real edits must still be saved");
+ const edited = changes.at(-1)!;
+ await rerender({ value: edited, editorMode: "preview" });
+ assert.ok(!querySourceFallback(rootNode));
+ assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, "Memory<2GB");
+ await rerender({ value: edited, editorMode: "source" });
+ assert.equal(rootNode.querySelector("textarea")?.value, edited);
+ } finally {
+ await unmount();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test("preview task checkboxes still save explicit changes without rewriting comparisons", async () => {
+ const { window, cleanup } = setupDom();
+ try {
+ const value = "- [ ] Memory<1GB\n- [x] Ready";
+ const { rootNode, changes, unmount } = await renderEditor(window, { value, editorMode: "preview" });
+ try {
+ assert.deepEqual(changes, []);
+ const task = rootNode.querySelector('li[role="checkbox"]');
+ assert.ok(task);
+ await runWithAct(async () => {
+ task.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 0 }));
+ });
+ assert.deepEqual(changes, ["- [x] Memory<1GB\n- [x] Ready"]);
+ assert.equal(rootNode.querySelector('li[role="checkbox"]')?.getAttribute("aria-checked"), "true");
+ } finally {
+ await unmount();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
+test("switching between a comparison note and an unsupported note keeps the fallback scoped", async () => {
+ const { window, cleanup } = setupDom();
+ try {
+ const value = "Memory<1GB";
+ const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value });
+ try {
+ for (const props of [
+ { noteId: "unsupported", value: NOTE_MARKDOWN_MDX_CANNOT_PARSE },
+ { noteId: "comparison", value },
+ ]) {
+ await rerender(props);
+ await runWithAct(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); });
+ if (props.noteId === "unsupported") {
+ assert.equal(querySourceFallback(rootNode)?.value, props.value);
+ } else {
+ assert.ok(!querySourceFallback(rootNode));
+ assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, value);
+ }
+ }
+ assert.deepEqual(changes, []);
+ } finally {
+ await unmount();
+ }
+ } finally {
+ cleanup();
+ }
+});
+
test("unrenderable markdown stays visible via the raw source fallback", async () => {
const { window, cleanup } = setupDom();
try {
diff --git a/components/notes/test-fixtures/issue-3205.md b/components/notes/test-fixtures/issue-3205.md
new file mode 100644
index 0000000000..ede7e98dac
--- /dev/null
+++ b/components/notes/test-fixtures/issue-3205.md
@@ -0,0 +1,121 @@
+# 设备TCP栈调优操作记录
+
+> **目标**:根据本地带宽动态调整TCP接收/发送缓冲区,使其能容纳带宽延迟积(BDP)。
+
+## 1. 环境信息收集
+
+### 1.1 基础硬件与系统信息
+```bash
+# 设备型号
+cat /tmp/sysinfo/model
+
+# 内核版本
+uname -r
+
+# 总内存
+grep MemTotal /proc/meminfo
+```
+
+### 1.2 当前TCP参数快照
+```bash
+sysctl net.ipv4.tcp_rmem \
+ net.ipv4.tcp_wmem \
+ net.core.rmem_max \
+ net.core.wmem_max \
+ net.ipv4.tcp_congestion_control \
+ net.core.netdev_max_backlog
+```
+
+### 1.3 网络链路与带宽测试
+- **查看WAN口速率**(替换``为实际接口名,如`eth0`):
+ ```bash
+ ethtool | grep -i speed
+ ```
+- **实测带宽**(确保无代理干扰):
+ ```bash
+ # 检查代理进程
+ ps | grep -E "openclash|mihomo"
+
+ # 若存在代理,请先关闭;然后测速
+ speedtest --accept-license --format=json
+ ```
+
+### 1.4 检查BBR拥塞控制算法支持
+```bash
+cat /proc/sys/net/ipv4/tcp_available_congestion_control
+```
+> **注意**:若输出不包含`bbr`,则后续配置保持`cubic`。
+
+---
+
+## 2. BDP计算与参数确定
+
+### 2.1 计算公式
+`最大缓冲区(MB) ≈ 带宽(Mbps) × 0.125 × 最大RTT(秒)`
+即:`带宽(Mbps) × 0.125 × (RTT_ms / 1000)`
+
+### 2.2 参数参考
+| 场景 | 预估RTT | 推荐`max`值 |
+| :--- | :--- | :--- |
+| 本地网络 | ~30ms | 根据实测带宽计算 |
+| 代理/国际链路 | ~150-200ms | 根据实测带宽计算 |
+| **内存<1GB设备** | - | **固定16MB** |
+| 大内存服务器 | - | 可参考GCP建议最大64MB |
+
+### 2.3 配置示例(16MB)
+若计算后决定使用16MB上限:
+```bash
+cat > /etc/sysctl.d/90-tcp-tuning.conf <<'EOF'
+net.core.rmem_max = 16777216
+net.core.wmem_max = 16777216
+net.ipv4.tcp_rmem = 4096 131072 16777216
+net.ipv4.tcp_wmem = 4096 16384 16777216
+net.core.netdev_max_backlog = 8192
+net.ipv4.tcp_mtu_probing = 1
+net.ipv4.tcp_fastopen = 3
+net.ipv4.tcp_slow_start_after_idle = 0
+EOF
+```
+
+---
+
+## 3. 应用与验证
+
+### 3.1 加载配置
+```bash
+# 应用配置文件(BusyBox使用-p参数)
+sysctl -p /etc/sysctl.d/90-tcp-tuning.conf
+```
+
+### 3.2 验证生效
+```bash
+# 检查关键参数
+sysctl net.ipv4.tcp_rmem net.core.rmem_max
+```
+
+### 3.3 本地环回吞吐测试
+```bash
+# 启动临时服务端(运行1次后退出)
+iperf3 -s -1 &
+
+# 客户端连接本机测试(3秒)
+iperf3 -c 127.0.0.1 -t 3
+```
+> **目的**:验证内核TCP栈本身是否正常。
+
+---
+
+## 4. 重要说明
+
+- **开机自动加载**:OpenWrt系统下,`/etc/init.d/sysctl`会自动遍历`/etc/sysctl.d/*.conf`,无需额外设置。
+- **BBR支持**:若内核不支持BBR,不添加`net.ipv4.tcp_congestion_control=bbr`,保留默认cubic。
+- **故障排查**:外网测速慢时,请先排除:
+ - 代理软件(openclash/mihomo)劫持流量。
+ - 上游运营商或国际链路瓶颈。
+- **配置回滚**:如需恢复默认设置,只需:
+ ```bash
+ rm /etc/sysctl.d/90-tcp-tuning.conf
+ sysctl -p /etc/sysctl.d/90-tcp-tuning.conf # 或重启设备
+ ```
+
+---
\ No newline at end of file
diff --git a/patches/micromark-extension-mdx-jsx+3.0.2.patch b/patches/micromark-extension-mdx-jsx+3.0.2.patch
new file mode 100644
index 0000000000..8df89d660c
--- /dev/null
+++ b/patches/micromark-extension-mdx-jsx+3.0.2.patch
@@ -0,0 +1,48 @@
+diff --git a/node_modules/micromark-extension-mdx-jsx/dev/lib/factory-tag.js b/node_modules/micromark-extension-mdx-jsx/dev/lib/factory-tag.js
+index 36d8f7f..9f9d6eb 100644
+--- a/node_modules/micromark-extension-mdx-jsx/dev/lib/factory-tag.js
++++ b/node_modules/micromark-extension-mdx-jsx/dev/lib/factory-tag.js
+@@ -151,6 +151,19 @@ export function factoryTag(
+ * @type {State}
+ */
+ function nameBefore(code) {
++ // Netcatty notes are Markdown: numeric comparisons cannot start JSX tags.
++ // Let micromark roll back this construct and keep the literal text.
++ if (
++ code !== codes.eof &&
++ ((code >= codes.digit0 && code <= codes.digit9) ||
++ code === codes.equalsTo ||
++ code === codes.plusSign ||
++ code === codes.dash ||
++ code === codes.dot)
++ ) {
++ return nok(code)
++ }
++
+ // Closing tag.
+ if (code === codes.slash) {
+ effects.enter(tagClosingMarkerType)
+diff --git a/node_modules/micromark-extension-mdx-jsx/lib/factory-tag.js b/node_modules/micromark-extension-mdx-jsx/lib/factory-tag.js
+index 6beab7e..b501f5e 100644
+--- a/node_modules/micromark-extension-mdx-jsx/lib/factory-tag.js
++++ b/node_modules/micromark-extension-mdx-jsx/lib/factory-tag.js
+@@ -108,6 +108,19 @@ export function factoryTag(effects, ok, nok, acorn, acornOptions, addResult, all
+ * @type {State}
+ */
+ function nameBefore(code) {
++ // Netcatty notes are Markdown: numeric comparisons cannot start JSX tags.
++ // Let micromark roll back this construct and keep the literal text.
++ if (
++ code !== null &&
++ ((code >= 48 && code <= 57) ||
++ code === 61 ||
++ code === 43 ||
++ code === 45 ||
++ code === 46)
++ ) {
++ return nok(code);
++ }
++
+ // Closing tag.
+ if (code === 47) {
+ effects.enter(tagClosingMarkerType);