From 3fd24c72fdd60b82bc2308993a23c4fc24207c1b Mon Sep 17 00:00:00 2001 From: Alfredo Arenas Date: Sat, 18 Apr 2026 07:15:38 -0600 Subject: [PATCH 01/10] fix(discord): allow bot-to-bot messaging, only drop self-loops (#3217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the Discord channel dropped every message from any bot account via `if message.author.bot`, which prevented legitimate multi-agent setups (one bot asking another for help, bot-to-bot @mentions, etc.) from working. Narrow the guard to only drop messages from this bot's own account by comparing against self._bot_user_id (already populated in on_ready). Self-loop protection is preserved — each bot instance still ignores its own outbound messages. Co-authored with Claude Opus 4.7 --- nanobot/channels/discord.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 60ca06982..9710c5efc 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -433,8 +433,15 @@ class DiscordChannel(BaseChannel): raise async def _handle_discord_message(self, message: discord.Message) -> None: - """Handle incoming Discord messages from discord.py.""" - if message.author.bot: + """Handle incoming Discord messages from discord.py. + + Self-loop guard: only drop messages from this bot's own account. Messages + from other bots are allowed through so multi-agent setups (one bot asking + another for help, a bot mentioning another by @name, etc.) can work. + Bot-from-bot loops are still prevented per-instance because each bot + still ignores its own outbound messages. (#3217) + """ + if self._bot_user_id is not None and str(message.author.id) == self._bot_user_id: return sender_id = str(message.author.id) From 5d976d79ff5e9313b387cca2ae10ab2acbfbea23 Mon Sep 17 00:00:00 2001 From: Alfredo Arenas Date: Sat, 18 Apr 2026 08:11:27 -0600 Subject: [PATCH 02/10] test(discord): update tests for bot-to-bot fix (#3217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old test `test_on_message_ignores_bot_messages` asserted the previous (incorrect) contract that ALL bot-authored messages are dropped. With #3217 only self-loops are dropped, so this test was replaced with three more precise tests: - test_on_message_ignores_self_messages: verifies self-loop guard (author_id == _bot_user_id is dropped) - test_on_message_accepts_messages_from_other_bots: new test for the fix itself — other bots' messages flow through - test_on_message_stops_typing_on_handle_exception: preserves the typing cleanup assertion from the original test Net result: +1 behavior tested, same behaviors retained. Co-authored with Claude Opus 4.7 --- tests/channels/test_discord_channel.py | 30 +++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/channels/test_discord_channel.py b/tests/channels/test_discord_channel.py index 82ef6c51b..a0a032270 100644 --- a/tests/channels/test_discord_channel.py +++ b/tests/channels/test_discord_channel.py @@ -273,17 +273,41 @@ async def test_stop_is_safe_after_partial_start(monkeypatch) -> None: @pytest.mark.asyncio -async def test_on_message_ignores_bot_messages() -> None: - # Incoming bot-authored messages must be ignored to prevent feedback loops. +async def test_on_message_ignores_self_messages() -> None: + # Self-loop guard: messages from this bot's own account must be dropped (#3217). channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + channel._bot_user_id = "999" # simulate bot identity populated in on_ready() handled: list[dict] = [] channel._handle_message = lambda **kwargs: handled.append(kwargs) # type: ignore[method-assign] - await channel._on_message(_make_message(author_bot=True)) + await channel._on_message(_make_message(author_id=999, author_bot=True)) assert handled == [] + +@pytest.mark.asyncio +async def test_on_message_accepts_messages_from_other_bots() -> None: + # Multi-agent setups: messages from OTHER bots must be processed, not dropped (#3217). + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + channel._bot_user_id = "999" + handled: list[dict] = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle # type: ignore[method-assign] + + await channel._on_message(_make_message(author_id=123, author_bot=True)) + + assert len(handled) == 1 + assert handled[0]["sender_id"] == "123" + + +@pytest.mark.asyncio +async def test_on_message_stops_typing_on_handle_exception() -> None: # If inbound handling raises, typing should be stopped for that channel. + channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus()) + async def fail_handle(**kwargs) -> None: raise RuntimeError("boom") From efb04a1712055eecce939f0d9275f664048fdf08 Mon Sep 17 00:00:00 2001 From: aiguozhi123456 <126325311+aiguozhi123456@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:36:16 +0800 Subject: [PATCH 03/10] fix(session): use atomic writes and add corrupt-file repair SessionManager.save() previously used bare open("w") which could truncate the JSONL file if the process crashed mid-write. Now writes to a .tmp file and atomically replaces via os.replace(), matching the pattern already used in qq.py. _load() now attempts _repair() before returning None, recovering valid lines from partially-written files. 12 new tests cover atomic save correctness, temp-file cleanup on failure, and repair of truncated/corrupt JSONL. cowork-with:opencode(glm-5.1) --- nanobot/session/manager.py | 96 +++++++++++-- tests/agent/test_session_atomic.py | 220 +++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+), 13 deletions(-) create mode 100644 tests/agent/test_session_atomic.py diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index c91eabcb3..c264649f1 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -1,6 +1,7 @@ """Session management for conversation history.""" import json +import os import shutil from dataclasses import dataclass, field from datetime import datetime @@ -187,24 +188,93 @@ class SessionManager: ) except Exception as e: logger.warning("Failed to load session {}: {}", key, e) + repaired = self._repair(key) + if repaired is not None: + logger.info("Recovered session {} from corrupt file ({} messages)", key, len(repaired.messages)) + return repaired + + def _repair(self, key: str) -> Session | None: + """Attempt to recover a session from a corrupt JSONL file.""" + path = self._get_session_path(key) + if not path.exists(): + return None + + try: + messages: list[dict[str, Any]] = [] + metadata: dict[str, Any] = {} + created_at: datetime | None = None + updated_at: datetime | None = None + last_consolidated = 0 + skipped = 0 + + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + skipped += 1 + continue + + if data.get("_type") == "metadata": + metadata = data.get("metadata", {}) + if data.get("created_at"): + try: + created_at = datetime.fromisoformat(data["created_at"]) + except (ValueError, TypeError): + pass + if data.get("updated_at"): + try: + updated_at = datetime.fromisoformat(data["updated_at"]) + except (ValueError, TypeError): + pass + last_consolidated = data.get("last_consolidated", 0) + else: + messages.append(data) + + if skipped: + logger.warning("Skipped {} corrupt lines in session {}", skipped, key) + + if not messages and not metadata: + return None + + return Session( + key=key, + messages=messages, + created_at=created_at or datetime.now(), + updated_at=updated_at or datetime.now(), + metadata=metadata, + last_consolidated=last_consolidated + ) + except Exception as e: + logger.warning("Repair failed for session {}: {}", key, e) return None def save(self, session: Session) -> None: - """Save a session to disk.""" + """Save a session to disk atomically.""" path = self._get_session_path(session.key) + tmp_path = path.with_suffix(".jsonl.tmp") - with open(path, "w", encoding="utf-8") as f: - metadata_line = { - "_type": "metadata", - "key": session.key, - "created_at": session.created_at.isoformat(), - "updated_at": session.updated_at.isoformat(), - "metadata": session.metadata, - "last_consolidated": session.last_consolidated - } - f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n") - for msg in session.messages: - f.write(json.dumps(msg, ensure_ascii=False) + "\n") + try: + with open(tmp_path, "w", encoding="utf-8") as f: + metadata_line = { + "_type": "metadata", + "key": session.key, + "created_at": session.created_at.isoformat(), + "updated_at": session.updated_at.isoformat(), + "metadata": session.metadata, + "last_consolidated": session.last_consolidated + } + f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n") + for msg in session.messages: + f.write(json.dumps(msg, ensure_ascii=False) + "\n") + + os.replace(tmp_path, path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise self._cache[session.key] = session diff --git a/tests/agent/test_session_atomic.py b/tests/agent/test_session_atomic.py new file mode 100644 index 000000000..4b84d8b6d --- /dev/null +++ b/tests/agent/test_session_atomic.py @@ -0,0 +1,220 @@ +"""Tests for atomic session save and corrupt-file repair.""" + +import json +from datetime import datetime +from pathlib import Path + +from nanobot.session.manager import Session, SessionManager + + +class TestAtomicSave: + def test_save_creates_valid_jsonl(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + session = Session(key="test:1") + session.add_message("user", "hello") + session.add_message("assistant", "hi") + + mgr.save(session) + + path = mgr._get_session_path("test:1") + lines = path.read_text(encoding="utf-8").strip().split("\n") + assert len(lines) == 3 + + meta = json.loads(lines[0]) + assert meta["_type"] == "metadata" + assert meta["key"] == "test:1" + + msg1 = json.loads(lines[1]) + assert msg1["role"] == "user" + assert msg1["content"] == "hello" + + def test_no_tmp_file_left_after_successful_save(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + session = Session(key="test:clean") + mgr.save(session) + + tmp_files = list(mgr.sessions_dir.glob("*.tmp")) + assert tmp_files == [] + + def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + session = Session(key="test:fail") + path = mgr._get_session_path("test:fail") + tmp_path_file = path.with_suffix(".jsonl.tmp") + + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path_file.write_text("stale") + + class BadMessage: + def __init__(self, data): + self.data = data + + original_dumps = json.dumps + + def failing_dumps(obj, **kwargs): + if isinstance(obj, dict) and obj.get("role") == "assistant": + raise OSError("simulated disk full") + return original_dumps(obj, **kwargs) + + session = Session(key="test:fail") + session.messages = [ + {"role": "user", "content": "ok"}, + {"role": "assistant", "content": "will fail"}, + ] + + import unittest.mock + with unittest.mock.patch("nanobot.session.manager.json.dumps", side_effect=failing_dumps): + try: + mgr.save(session) + except OSError: + pass + + assert not tmp_path_file.exists() + + def test_overwrite_preserves_latest_data(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + session = Session(key="test:overwrite") + + session.add_message("user", "first") + mgr.save(session) + + session.add_message("user", "second") + mgr.save(session) + + mgr.invalidate("test:overwrite") + loaded = mgr.get_or_create("test:overwrite") + assert len(loaded.messages) == 2 + assert loaded.messages[0]["content"] == "first" + assert loaded.messages[1]["content"] == "second" + + def test_consecutive_saves_are_consistent(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + session = Session(key="test:consistency") + + for i in range(5): + session.add_message("user", f"msg{i}") + mgr.save(session) + + mgr.invalidate("test:consistency") + loaded = mgr.get_or_create("test:consistency") + assert len(loaded.messages) == 5 + for i in range(5): + assert loaded.messages[i]["content"] == f"msg{i}" + + +class TestRepairCorruptFile: + def _write_corrupt_jsonl(self, path: Path, lines: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + def test_truncated_last_line_recovered(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + path = mgr._get_session_path("test:trunc") + + valid_meta = json.dumps({ + "_type": "metadata", + "key": "test:trunc", + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + "metadata": {}, + "last_consolidated": 0, + }) + valid_msg = json.dumps({"role": "user", "content": "hello"}) + + self._write_corrupt_jsonl(path, [ + valid_meta, + valid_msg, + '{"role": "assistant", "content": "partial...', + ]) + + session = mgr._load("test:trunc") + assert session is not None + assert len(session.messages) == 1 + assert session.messages[0]["content"] == "hello" + + def test_corrupt_metadata_line_skipped(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + path = mgr._get_session_path("test:badmeta") + + self._write_corrupt_jsonl(path, [ + "NOT VALID JSON!!!", + '{"role": "user", "content": "survived"}', + ]) + + session = mgr._load("test:badmeta") + assert session is not None + assert len(session.messages) == 1 + assert session.messages[0]["content"] == "survived" + + def test_all_corrupt_lines_returns_none(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + path = mgr._get_session_path("test:allbad") + + self._write_corrupt_jsonl(path, [ + "garbage line 1", + "garbage line 2", + "{{invalid json", + ]) + + session = mgr._load("test:allbad") + assert session is None + + def test_empty_file_returns_empty_session(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + path = mgr._get_session_path("test:empty") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("", encoding="utf-8") + + session = mgr._load("test:empty") + assert session is not None + assert session.messages == [] + assert session.key == "test:empty" + + def test_repair_preserves_valid_messages_amid_corruption(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + path = mgr._get_session_path("test:mixed") + + self._write_corrupt_jsonl(path, [ + json.dumps({"_type": "metadata", "key": "test:mixed", + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + "metadata": {}, "last_consolidated": 0}), + "BROKEN", + json.dumps({"role": "user", "content": "msg1"}), + '{"role": "assistant", "content": "broken', + json.dumps({"role": "user", "content": "msg2"}), + ]) + + session = mgr._load("test:mixed") + assert session is not None + assert len(session.messages) == 2 + assert session.messages[0]["content"] == "msg1" + assert session.messages[1]["content"] == "msg2" + + def test_repair_with_bad_timestamp_uses_fallback(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + path = mgr._get_session_path("test:badts") + + self._write_corrupt_jsonl(path, [ + json.dumps({"_type": "metadata", "key": "test:badts", + "created_at": "not-a-date", + "updated_at": "also-bad", + "metadata": {}, "last_consolidated": 5}), + json.dumps({"role": "user", "content": "hi"}), + ]) + + session = mgr._load("test:badts") + assert session is not None + assert session.last_consolidated == 5 + assert isinstance(session.created_at, datetime) + + def test_get_or_create_returns_new_session_for_corrupt_file(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + path = mgr._get_session_path("test:fallback") + + self._write_corrupt_jsonl(path, ["{{{{"]) + + session = mgr.get_or_create("test:fallback") + assert session is not None + assert session.messages == [] + assert session.key == "test:fallback" From 56a779c12878450c0bfead580daecee7c7b0fa97 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 19 Apr 2026 15:25:37 +0000 Subject: [PATCH 04/10] fix(session): repair read-only corrupt session paths --- nanobot/session/manager.py | 23 ++++++++++++++++ tests/agent/test_session_atomic.py | 43 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index c264649f1..4add4fd3b 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -252,6 +252,16 @@ class SessionManager: logger.warning("Repair failed for session {}: {}", key, e) return None + @staticmethod + def _session_payload(session: Session) -> dict[str, Any]: + return { + "key": session.key, + "created_at": session.created_at.isoformat(), + "updated_at": session.updated_at.isoformat(), + "metadata": session.metadata, + "messages": session.messages, + } + def save(self, session: Session) -> None: """Save a session to disk atomically.""" path = self._get_session_path(session.key) @@ -335,6 +345,10 @@ class SessionManager: } except Exception as e: logger.warning("Failed to read session {}: {}", key, e) + repaired = self._repair(key) + if repaired is not None: + logger.info("Recovered read-only session view {} from corrupt file", key) + return self._session_payload(repaired) return None def list_sessions(self) -> list[dict[str, Any]]: @@ -347,6 +361,7 @@ class SessionManager: sessions = [] for path in self.sessions_dir.glob("*.jsonl"): + fallback_key = path.stem.replace("_", ":", 1) try: # Read just the metadata line with open(path, encoding="utf-8") as f: @@ -362,6 +377,14 @@ class SessionManager: "path": str(path) }) except Exception: + repaired = self._repair(fallback_key) + if repaired is not None: + sessions.append({ + "key": repaired.key, + "created_at": repaired.created_at.isoformat(), + "updated_at": repaired.updated_at.isoformat(), + "path": str(path) + }) continue return sorted(sessions, key=lambda x: x.get("updated_at", ""), reverse=True) diff --git a/tests/agent/test_session_atomic.py b/tests/agent/test_session_atomic.py index 4b84d8b6d..4720c028a 100644 --- a/tests/agent/test_session_atomic.py +++ b/tests/agent/test_session_atomic.py @@ -208,6 +208,49 @@ class TestRepairCorruptFile: assert session.last_consolidated == 5 assert isinstance(session.created_at, datetime) + def test_read_session_file_repairs_corrupt_jsonl(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + path = mgr._get_session_path("test:read-repair") + + self._write_corrupt_jsonl(path, [ + json.dumps({ + "_type": "metadata", + "key": "test:read-repair", + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + "metadata": {"source": "repair"}, + "last_consolidated": 0, + }), + json.dumps({"role": "user", "content": "survived"}), + '{"role": "assistant", "content": "partial...', + ]) + + payload = mgr.read_session_file("test:read-repair") + assert payload is not None + assert payload["key"] == "test:read-repair" + assert payload["metadata"] == {"source": "repair"} + assert payload["messages"] == [{"role": "user", "content": "survived"}] + + def test_list_sessions_keeps_repaired_corrupt_file(self, tmp_path: Path): + mgr = SessionManager(tmp_path) + path = mgr._get_session_path("test:list-repair") + + self._write_corrupt_jsonl(path, [ + "NOT VALID JSON", + json.dumps({ + "_type": "metadata", + "key": "test:list-repair", + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + "metadata": {}, + "last_consolidated": 0, + }), + json.dumps({"role": "user", "content": "hello"}), + ]) + + sessions = mgr.list_sessions() + assert any(s["key"] == "test:list-repair" for s in sessions) + def test_get_or_create_returns_new_session_for_corrupt_file(self, tmp_path: Path): mgr = SessionManager(tmp_path) path = mgr._get_session_path("test:fallback") From a3adec08a99951dd1ec78947c431bce2a2c829dc Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 20 Apr 2026 00:03:38 +0800 Subject: [PATCH 05/10] style(webui): improve typography with Apple-inspired font stack and CJK support - Add explicit CJK fonts (PingFang SC, Noto Sans SC, Microsoft YaHei) and programmer fonts (JetBrains Mono, Fira Code, Cascadia Code) to Tailwind config - Bump prose base size from prose-sm (14px) to prose-lg (18px) for sharper CJK rendering - Unify user/assistant message font size at 18px with CJK-aware line-height (1.8) - Replace pure black/white foreground with Apple-style warm grays (#1d1d1f / #f5f5f7) - Override Tailwind Typography colors to use design tokens for consistency - Add negative letter-spacing on headings for tighter, more polished look --- webui/index.html | 6 ++- webui/src/components/MarkdownTextRenderer.tsx | 9 ++-- webui/src/components/MessageBubble.tsx | 4 +- webui/src/globals.css | 46 +++++++++++++------ webui/tailwind.config.js | 28 +++++++++++ 5 files changed, 72 insertions(+), 21 deletions(-) diff --git a/webui/index.html b/webui/index.html index 24b775ccb..92fb88c1b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -26,8 +26,10 @@ background: #ffffff; color: #0a0a0a; font-family: - ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; + system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, "Noto Sans", "Noto Sans SC", + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", + sans-serif; } html.dark body { diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index fce32a982..fe9ab40ed 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -24,10 +24,10 @@ export default function MarkdownTextRenderer({ return (
@@ -49,7 +49,7 @@ export function MessageBubble({ message }: MessageBubbleProps) { const empty = message.content.trim().length === 0; return ( -
+
{empty && message.isStreaming ? ( ) : ( diff --git a/webui/src/globals.css b/webui/src/globals.css index d2a24fd10..1c677432c 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -6,12 +6,12 @@ @layer base { :root { --background: 0 0% 100%; - --foreground: 0 0% 3.9%; + --foreground: 240 3% 12%; --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; + --card-foreground: 240 3% 12%; --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; + --popover-foreground: 240 3% 12%; + --primary: 240 4% 16%; --primary-foreground: 0 0% 98%; --secondary: 0 0% 96.1%; --secondary-foreground: 0 0% 9%; @@ -34,12 +34,12 @@ .dark { --background: 0 0% 10%; - --foreground: 0 0% 98%; + --foreground: 240 4% 96%; --card: 0 0% 12%; - --card-foreground: 0 0% 98%; + --card-foreground: 240 4% 96%; --popover: 0 0% 12%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; + --popover-foreground: 240 4% 96%; + --primary: 240 5% 98%; --primary-foreground: 0 0% 9%; --secondary: 0 0% 12%; --secondary-foreground: 0 0% 98%; @@ -72,11 +72,7 @@ } body { - @apply bg-background text-foreground antialiased; - font-family: - ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, - "Apple Color Emoji", "Segoe UI Emoji"; + @apply bg-background text-foreground font-sans antialiased; } ::selection { @@ -97,6 +93,30 @@ @apply mb-0; } + /* Override Tailwind Typography's built-in colors with our design tokens + so assistant messages use the same --foreground as user messages. */ + .markdown-content { + --tw-prose-body: hsl(var(--foreground)); + --tw-prose-headings: hsl(var(--foreground)); + --tw-prose-bold: hsl(var(--foreground)); + --tw-prose-lead: hsl(var(--foreground)); + } + + /* CJK-friendly line-height: prose paragraphs default to 1.625 which is + tight for Chinese/Japanese/Korean characters. Bump to 1.8 for better + readability when the browser detects a CJK primary font. */ + :lang(zh), + :lang(zh-CN), + :lang(zh-TW), + :lang(zh-HK), + :lang(ja), + :lang(ko) { + --cjk-line-height: 1.8; + } + :root { + --cjk-line-height: 1.625; + } + /* Subtle scrollbar that doesn't fight the dark background. */ .scrollbar-thin { scrollbar-width: thin; diff --git a/webui/tailwind.config.js b/webui/tailwind.config.js index 510334d4b..0998f1bd9 100644 --- a/webui/tailwind.config.js +++ b/webui/tailwind.config.js @@ -14,6 +14,34 @@ export default { }, }, extend: { + fontFamily: { + sans: [ + "system-ui", + "-apple-system", + "BlinkMacSystemFont", + '"Segoe UI"', + "Roboto", + '"Helvetica Neue"', + "Arial", + '"Noto Sans"', + '"Noto Sans SC"', + '"PingFang SC"', + '"Hiragino Sans GB"', + '"Microsoft YaHei"', + "sans-serif", + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + ], + mono: [ + '"JetBrains Mono"', + '"Fira Code"', + '"Cascadia Code"', + '"Source Code Pro"', + "Menlo", + "Consolas", + "monospace", + ], + }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", From 8eddacf2f8084f781e13f69b4d63b518f43914be Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 20 Apr 2026 00:17:22 +0800 Subject: [PATCH 06/10] fix(webui): sync code block theme with dark mode toggle instantly - Replace one-time DOM read with MutationObserver on class - Remove hardcoded #0a0a0a background, let oneDark/oneLight own it - Add light-mode header/copy-button colors (bg-zinc-100 for light) - Bump font size from 13px to 14px, line-height from 1.55 to 1.6 - Add subtle border to distinguish code block edges --- webui/src/components/CodeBlock.tsx | 63 +++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/webui/src/components/CodeBlock.tsx b/webui/src/components/CodeBlock.tsx index 68032d29b..c19a78645 100644 --- a/webui/src/components/CodeBlock.tsx +++ b/webui/src/components/CodeBlock.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Check, Copy } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; @@ -15,33 +15,67 @@ interface CodeBlockProps { className?: string; } +/** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */ +function useIsDark() { + const [isDark, setIsDark] = useState(() => + typeof document !== "undefined" + ? document.documentElement.classList.contains("dark") + : true, + ); + + useEffect(() => { + const el = document.documentElement; + const observer = new MutationObserver(() => { + setIsDark(el.classList.contains("dark")); + }); + observer.observe(el, { attributeFilter: ["class"] }); + return () => observer.disconnect(); + }, []); + + return isDark; +} + export function CodeBlock({ language, code, className }: CodeBlockProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); + const isDark = useIsDark(); - const onCopy = () => { + const onCopy = useCallback(() => { if (!navigator.clipboard) return; navigator.clipboard.writeText(code).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1_500); }); - }; - - const isDark = - typeof window !== "undefined" - ? document.documentElement.classList.contains("dark") - : true; + }, [code]); return ( -
-
- +
+
+ {language || t("code.fallbackLanguage")}